diff --git a/app/src/main/java/com/byeboo/app/data/dto/response/quest/QuestRecordedDetailResponseDto.kt b/app/src/main/java/com/byeboo/app/data/dto/response/quest/QuestRecordedDetailResponseDto.kt index f7fb489f8..4b51ac284 100644 --- a/app/src/main/java/com/byeboo/app/data/dto/response/quest/QuestRecordedDetailResponseDto.kt +++ b/app/src/main/java/com/byeboo/app/data/dto/response/quest/QuestRecordedDetailResponseDto.kt @@ -23,4 +23,6 @@ data class QuestRecordedDetailResponseDto( val questEmotionState: String, @SerialName("emotionDescription") val emotionDescription: String, + @SerialName("aiAnswerExists") + val aiAnswerExists: Boolean, ) diff --git a/app/src/main/java/com/byeboo/app/data/mapper/quest/QuestRecordedDetailMapper.kt b/app/src/main/java/com/byeboo/app/data/mapper/quest/QuestRecordedDetailMapper.kt index 693acd6cf..f2c5e6c48 100644 --- a/app/src/main/java/com/byeboo/app/data/mapper/quest/QuestRecordedDetailMapper.kt +++ b/app/src/main/java/com/byeboo/app/data/mapper/quest/QuestRecordedDetailMapper.kt @@ -14,4 +14,5 @@ fun QuestRecordedDetailResponseDto.toDomain(): QuestRecordedDetailModel = imageKey = this.imageKey, imageUrl = this.imageUrl, emotionDescription = this.emotionDescription, + aiAnswerExists = this.aiAnswerExists, ) diff --git a/app/src/main/java/com/byeboo/app/domain/model/quest/QuestRecordedDetailModel.kt b/app/src/main/java/com/byeboo/app/domain/model/quest/QuestRecordedDetailModel.kt index 22548be9c..ab174511b 100644 --- a/app/src/main/java/com/byeboo/app/domain/model/quest/QuestRecordedDetailModel.kt +++ b/app/src/main/java/com/byeboo/app/domain/model/quest/QuestRecordedDetailModel.kt @@ -10,4 +10,5 @@ data class QuestRecordedDetailModel( val imageKey: String? = null, val imageUrl: String? = null, val emotionDescription: String, + val aiAnswerExists: Boolean, ) diff --git a/app/src/main/java/com/byeboo/app/presentation/main/MainNavHost.kt b/app/src/main/java/com/byeboo/app/presentation/main/MainNavHost.kt index 5ad2726c5..2a1fdd566 100644 --- a/app/src/main/java/com/byeboo/app/presentation/main/MainNavHost.kt +++ b/app/src/main/java/com/byeboo/app/presentation/main/MainNavHost.kt @@ -94,6 +94,7 @@ fun MainNavHost( navigateToHome = { navigator.navigateToHome(questNavOptions) }, navigateToQuestRecording = { questId -> navigator.navigateToQuestRecording(questId) }, navigateToQuestBehavior = { questId -> navigator.navigateToQuestBehavior(questId) }, + navigateToQuestCommon = { questId -> navigator.navigateToQuestCommon(questId) }, navigateToQuestReview = { questId -> navigator.navigateToQuestReview(questId) }, navigateToOffboardingCompletedGuide = { navigator.navigateToOffboardingCompletedGuide( @@ -133,6 +134,12 @@ fun MainNavHost( navOptions = clearStackNavOptions, ) }, + navigateToQuestCommonComplete = { questId -> + navigator.navigateToQuestCommonComplete( + questId = questId, + navOptions = clearStackNavOptions, + ) + }, navigateUp = navigator::navigateUp, paddingValues = paddingValues, ) diff --git a/app/src/main/java/com/byeboo/app/presentation/main/MainNavigator.kt b/app/src/main/java/com/byeboo/app/presentation/main/MainNavigator.kt index 337997629..0336e9cb3 100644 --- a/app/src/main/java/com/byeboo/app/presentation/main/MainNavigator.kt +++ b/app/src/main/java/com/byeboo/app/presentation/main/MainNavigator.kt @@ -26,6 +26,8 @@ import com.byeboo.app.presentation.offboarding.navigation.navigateToOffboardingQ import com.byeboo.app.presentation.offboarding.navigation.navigateToOffboardingQuestReview import com.byeboo.app.presentation.quest.behavior.navigation.navigateToQuestBehavior import com.byeboo.app.presentation.quest.behavior.navigation.navigateToQuestBehaviorComplete +import com.byeboo.app.presentation.quest.common.navigation.navigateToQuestCommon +import com.byeboo.app.presentation.quest.common.navigation.navigateToQuestCommonComplete import com.byeboo.app.presentation.quest.navigation.Quest import com.byeboo.app.presentation.quest.navigation.navigateToQuest import com.byeboo.app.presentation.quest.navigation.navigateToQuestReview @@ -184,6 +186,12 @@ class MainNavigator( ) } + fun navigateToQuestCommon(questId: Long) { + navController.navigateToQuestCommon( + questId = questId, + ) + } + fun navigateToQuestRecordingComplete( questId: Long, navOptions: NavOptions? = null, @@ -198,6 +206,13 @@ class MainNavigator( navController.navigateToQuestBehaviorComplete(questId = questId, navOptions = navOptions) } + fun navigateToQuestCommonComplete( + questId: Long, + navOptions: NavOptions? = null, + ) { + navController.navigateToQuestCommonComplete(questId = questId, navOptions = navOptions) + } + fun navigateToQuestReview( questId: Long, navOptions: NavOptions? = null, diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/QuestScreen.kt b/app/src/main/java/com/byeboo/app/presentation/quest/QuestScreen.kt index 8819c87ea..dfc956526 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/QuestScreen.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/QuestScreen.kt @@ -36,6 +36,7 @@ fun QuestRoute( navigateToQuestTip: (Long, QuestType) -> Unit, navigateToQuestRecording: (Long) -> Unit, navigateToQuestBehavior: (Long) -> Unit, + navigateToQuestCommon: (Long) -> Unit, navigateToQuestReview: (Long) -> Unit, paddingValues: PaddingValues, viewModel: QuestViewModel = hiltViewModel(), @@ -66,6 +67,8 @@ fun QuestRoute( navigateToQuestRecording(effect.questId) is QuestSideEffect.NavigateToQuestBehavior -> navigateToQuestBehavior(effect.questId) + is QuestSideEffect.NavigateToQuestCommon -> + navigateToQuestCommon(effect.questId) is QuestSideEffect.NavigateToQuestReview -> navigateToQuestReview(effect.questId) is QuestSideEffect.ShowSnackBar -> @@ -79,6 +82,7 @@ fun QuestRoute( listState = listState, paddingValues = paddingValues, onQuestClick = viewModel::onQuestClick, + onCommonQuestClick = navigateToQuestCommon, onDismissModal = viewModel::onQuitDismissModal, onTipClick = viewModel::onTipClick, onQuestStart = viewModel::onQuestStart, @@ -93,6 +97,7 @@ private fun QuestScreen( listState: LazyListState, paddingValues: PaddingValues, onQuestClick: (Long) -> Unit, + onCommonQuestClick: (Long) -> Unit, onDismissModal: () -> Unit, onTipClick: () -> Unit, onQuestStart: () -> Unit, @@ -146,6 +151,7 @@ private fun QuestScreen( CommonJourneyScreen( state = uiState.commonJourneyState, onDateChange = onDateChange, + onCommonQuestClick = onCommonQuestClick, ) } } diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorCompleteScreen.kt b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorCompleteScreen.kt deleted file mode 100644 index 0d6b51aee..000000000 --- a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorCompleteScreen.kt +++ /dev/null @@ -1,295 +0,0 @@ -package com.byeboo.app.presentation.quest.behavior - -import android.net.Uri -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil.compose.SubcomposeAsyncImage -import coil.request.ImageRequest -import com.byeboo.app.R -import com.byeboo.app.core.designsystem.component.tag.SmallTag -import com.byeboo.app.core.designsystem.component.text.ContentText -import com.byeboo.app.core.designsystem.event.LocalSnackBarTrigger -import com.byeboo.app.core.designsystem.ui.theme.ByeBooTheme -import com.byeboo.app.core.util.findActivity -import com.byeboo.app.core.util.inAppReview -import com.byeboo.app.core.util.screenHeightDp -import com.byeboo.app.core.util.screenWidthDp -import com.byeboo.app.presentation.quest.component.card.QuestCompleteCard -import com.byeboo.app.presentation.quest.component.card.QuestEmotionDescriptionCard -import com.byeboo.app.presentation.quest.component.text.CreatedText - -@Composable -fun QuestBehaviorCompleteRoute( - navigateToQuest: () -> Unit, - navigateToOffboardingCompletedGuide: () -> Unit, - paddingValues: PaddingValues, - modifier: Modifier = Modifier, - viewModel: QuestBehaviorCompleteViewModel = hiltViewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val showSnackBar = LocalSnackBarTrigger.current - val context = LocalContext.current - val activity = context.findActivity() - - val imageUri = - when { - uiState.selectedImageUri != null -> uiState.selectedImageUri - uiState.imageUrl.isNotBlank() -> uiState.imageUrl.toUri() - else -> null - } - - LaunchedEffect(Unit) { - viewModel.sideEffect.collect { effect -> - when (effect) { - is QuestBehaviorCompleteSideEffect.NavigateToQuest -> navigateToQuest() - is QuestBehaviorCompleteSideEffect.NavigateToOffboardingCompletedGuide -> navigateToOffboardingCompletedGuide() - is QuestBehaviorCompleteSideEffect.ShowInAppReview -> { - activity?.let { activity -> - inAppReview(activity) - } - } - is QuestBehaviorCompleteSideEffect.ShowSnackBar -> showSnackBar(effect.message) - } - } - } - - BackHandler { viewModel.onCloseClicked() } - - QuestBehaviorCompleteScreen( - uiState = uiState, - paddingValues = paddingValues, - onCloseClick = viewModel::onCloseClicked, - imageUri = imageUri, - modifier = modifier, - ) -} - -@Composable -private fun QuestBehaviorCompleteScreen( - uiState: QuestBehaviorCompleteState, - paddingValues: PaddingValues, - onCloseClick: () -> Unit, - imageUri: Uri?, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .fillMaxSize() - .background(ByeBooTheme.colors.background) - .padding( - top = paddingValues.calculateTopPadding() + screenHeightDp(43.dp), - bottom = paddingValues.calculateBottomPadding(), - ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = screenWidthDp(24.dp)), - horizontalArrangement = Arrangement.End, - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_cancel), - contentDescription = "back button", - tint = ByeBooTheme.colors.white, - modifier = modifier.clickable(onClick = onCloseClick), - ) - } - - Spacer(modifier = modifier.height(screenHeightDp(16.dp))) - - LazyColumn( - modifier = modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = - PaddingValues( - start = screenWidthDp(24.dp), - top = screenHeightDp(8.dp), - end = screenWidthDp(24.dp), - bottom = screenHeightDp(24.dp), - ), - ) { - item { - QuestCompleteCard( - modifier = modifier.fillMaxWidth(), - ) - - Spacer(modifier = modifier.height(screenHeightDp(32.dp))) - } - - item { - Column( - modifier = modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Row( - modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - SmallTag( - tagText = "STEP ${uiState.stepNumber}", - tagColor = ByeBooTheme.colors.gray500, - ) - - Spacer(modifier = modifier.width(screenWidthDp(8.dp))) - - Text( - text = "${uiState.questNumber}번째 퀘스트", - style = ByeBooTheme.typography.body6, - color = ByeBooTheme.colors.gray500, - ) - } - - Spacer(modifier = modifier.height(screenHeightDp(12.dp))) - - CreatedText(uiState.createdAt) - - Spacer(modifier = modifier.height(screenHeightDp(12.dp))) - - Text( - text = uiState.question, - style = ByeBooTheme.typography.head1, - color = ByeBooTheme.colors.gray100, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - - Spacer(modifier = modifier.height(screenHeightDp(24.dp))) - } - } - - item { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_shoe), - contentDescription = "title icon", - tint = Color.Unspecified, - ) - - Spacer(modifier = Modifier.width(screenWidthDp(8.dp))) - - Text( - text = "이렇게 완료했어요", - color = ByeBooTheme.colors.gray200, - style = ByeBooTheme.typography.body2, - ) - } - - Spacer(modifier = modifier.height(screenHeightDp(12.dp))) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Column( - modifier = - modifier - .fillMaxWidth() - .aspectRatio(312 / 312f) - .clip(RoundedCornerShape(12.dp)), - ) { - if (imageUri != null) { - SubcomposeAsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(imageUri) - .memoryCachePolicy(coil.request.CachePolicy.DISABLED) - .diskCachePolicy(coil.request.CachePolicy.DISABLED) - .build(), - contentDescription = "uploaded image", - modifier = modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - loading = { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - }, - ) - } - } - - if (uiState.questAnswer.isNotBlank()) { - Spacer(modifier = modifier.height(screenHeightDp(12.dp))) - - ContentText(uiState.questAnswer) - } - - Spacer(modifier = modifier.height(screenHeightDp(24.dp))) - } - } - - item { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_change), - contentDescription = "title icon", - tint = Color.Unspecified, - ) - - Spacer(modifier = Modifier.width(screenWidthDp(8.dp))) - - Text( - text = "퀘스트 완료 후, 이런 감정을 느꼈어요", - color = ByeBooTheme.colors.gray200, - style = ByeBooTheme.typography.body2, - ) - } - - Spacer(modifier = modifier.height(screenHeightDp(12.dp))) - - uiState.selectedEmotion?.let { emotion -> - QuestEmotionDescriptionCard( - questEmotionDescription = uiState.emotionDescription, - emotionType = emotion, - ) - } - } - } - } -} diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorWritingScreen.kt b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorWritingScreen.kt deleted file mode 100644 index f790ee3e1..000000000 --- a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorWritingScreen.kt +++ /dev/null @@ -1,376 +0,0 @@ -package com.byeboo.app.presentation.quest.behavior - -import QuestPhotoPicker -import android.content.Context -import android.net.Uri -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.nativeKeyCode -import androidx.compose.ui.input.key.onPreInterceptKeyBeforeSoftKeyboard -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.byeboo.app.R -import com.byeboo.app.core.designsystem.component.button.ByeBooActivationButton -import com.byeboo.app.core.designsystem.component.tag.MiddleTag -import com.byeboo.app.core.designsystem.component.tag.SmallTag -import com.byeboo.app.core.designsystem.event.LocalSnackBarTrigger -import com.byeboo.app.core.designsystem.type.EmotionChipType -import com.byeboo.app.core.designsystem.type.MiddleTagType -import com.byeboo.app.core.designsystem.ui.theme.ByeBooTheme -import com.byeboo.app.core.model.quest.QuestType -import com.byeboo.app.core.util.addFocusCleaner -import com.byeboo.app.core.util.screenHeightDp -import com.byeboo.app.core.util.screenWidthDp -import com.byeboo.app.presentation.quest.component.bottomsheet.ByeBooBottomSheet -import com.byeboo.app.presentation.quest.component.modal.QuestQuitModal -import com.byeboo.app.presentation.quest.component.text.textfield.QuestTextField -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun QuestBehaviorWritingRoute( - navigateToQuest: () -> Unit, - navigateToQuestTip: (Long, QuestType) -> Unit, - navigateToQuestBehaviorComplete: (Long) -> Unit, - navigateToQuestReview: (Long) -> Unit, - navigateUp: () -> Unit, - paddingValues: PaddingValues, - modifier: Modifier = Modifier, - viewModel: QuestBehaviorViewModel = hiltViewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val showSnackBar = LocalSnackBarTrigger.current - - LaunchedEffect(Unit) { - viewModel.sideEffect.collectLatest { effect -> - when (effect) { - is QuestBehaviorSideEffect.NavigateToQuest -> navigateToQuest() - is QuestBehaviorSideEffect.NavigateToQuestTip -> - navigateToQuestTip( - effect.questId, - effect.questType, - ) - is QuestBehaviorSideEffect.NavigateToQuestBehaviorComplete -> - navigateToQuestBehaviorComplete( - effect.questId, - ) - is QuestBehaviorSideEffect.CompleteAndClear -> viewModel.clearQuestInput() - is QuestBehaviorSideEffect.NavigateToQuestReview -> - navigateToQuestReview( - effect.questId, - ) - is QuestBehaviorSideEffect.NavigateUp -> navigateUp() - is QuestBehaviorSideEffect.ShowSnackBar -> showSnackBar(effect.message) - } - } - } - - if (uiState.showQuitModal) { - QuestQuitModal( - onDismissRequest = viewModel::onDismissModal, - stayButton = viewModel::onDismissModal, - quitButton = { - viewModel.onDismissModal() - viewModel.onQuitClicked() - }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = screenWidthDp(48.dp)), - ) - } - - BackHandler { viewModel.onBackClicked() } - - QuestBehaviorWritingScreen( - uiState = uiState, - paddingValues = paddingValues, - onBackClick = viewModel::onBackClicked, - onTipClick = viewModel::onTipClicked, - onUpdateSelectedImage = viewModel::updateSelectedImage, - onUpdateContent = viewModel::updateContent, - navigateButton = viewModel::uploadImage, - onClickCompleteButton = viewModel::onClickCompleteButton, - onBottomSheetDismiss = viewModel::closeBottomSheet, - onEmotionSelected = { selectedEmotion -> viewModel.updateSelectedEmotion(selectedEmotion) }, - modifier = modifier, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun QuestBehaviorWritingScreen( - uiState: QuestBehaviorState, - paddingValues: PaddingValues, - onBackClick: () -> Unit, - onTipClick: () -> Unit, - onUpdateSelectedImage: (Uri?) -> Unit, - onClickCompleteButton: (Context) -> Unit, - onUpdateContent: (String) -> Unit, - navigateButton: (Context) -> Unit, - onBottomSheetDismiss: () -> Unit, - onEmotionSelected: (EmotionChipType?) -> Unit, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val focusManager = LocalFocusManager.current - val bringIntoViewRequester = remember { BringIntoViewRequester() } - val isFocused = remember { mutableStateOf(false) } - val displayImageUri: Uri? = - uiState.selectedImageUri - ?: uiState.imageUrl.takeIf { it.isNotBlank() }?.toUri() - - LaunchedEffect(isFocused.value) { - if (isFocused.value) { - delay(300) - bringIntoViewRequester.bringIntoView() - } - } - - Column( - modifier = - modifier - .fillMaxSize() - .background(ByeBooTheme.colors.background) - .onPreInterceptKeyBeforeSoftKeyboard { event -> - if (event.key.nativeKeyCode == android.view.KeyEvent.KEYCODE_BACK) { - focusManager.clearFocus(force = true) - isFocused.value = false - true - } else { - false - } - }.addFocusCleaner(focusManager) - .padding( - top = paddingValues.calculateTopPadding() + screenHeightDp(43.dp), - bottom = paddingValues.calculateBottomPadding(), - ), - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_left), - contentDescription = "back button", - tint = ByeBooTheme.colors.white, - modifier = - modifier - .padding(horizontal = screenWidthDp(24.dp)) - .align(Alignment.Start) - .clickable(onClick = onBackClick), - ) - - Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) - - LazyColumn( - modifier = modifier.fillMaxWidth(), - contentPadding = - PaddingValues( - start = screenWidthDp(24.dp), - end = screenWidthDp(24.dp), - ), - ) { - item { - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - SmallTag( - tagText = "STEP ${uiState.stepNumber}", - tagColor = ByeBooTheme.colors.gray500, - ) - - Spacer(modifier = modifier.width(screenWidthDp(12.dp))) - - Text( - text = uiState.step, - color = ByeBooTheme.colors.gray500, - style = ByeBooTheme.typography.body2, - ) - } - - Spacer(modifier = modifier.height(screenHeightDp(12.dp))) - } - - item { - Text( - text = "${uiState.questNumber}번째 퀘스트", - color = ByeBooTheme.colors.gray500, - textAlign = TextAlign.Center, - style = ByeBooTheme.typography.body6, - modifier = modifier.fillMaxWidth(), - ) - - Spacer(modifier = modifier.height(screenHeightDp(12.dp))) - } - - item { - Text( - text = uiState.question, - color = ByeBooTheme.colors.gray100, - textAlign = TextAlign.Center, - style = ByeBooTheme.typography.head1, - modifier = modifier.fillMaxWidth(), - ) - - Spacer(modifier = modifier.height(screenHeightDp(25.dp))) - } - - item { - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - MiddleTag( - middleTagType = MiddleTagType.QUEST_TIP, - text = "작성 TIP", - textStyle = ByeBooTheme.typography.cap1, - modifier = modifier.clickable { onTipClick() }, - ) - } - - Spacer(modifier = modifier.height(screenHeightDp(16.dp))) - } - - item { - Row(verticalAlignment = Alignment.CenterVertically) { - MiddleTag( - middleTagType = MiddleTagType.QUEST_ESSENTIAL, - text = "필수", - textStyle = ByeBooTheme.typography.cap1, - ) - - Spacer(modifier = modifier.width(screenWidthDp(8.dp))) - - Text( - text = "사진 첨부", - color = ByeBooTheme.colors.gray50, - style = ByeBooTheme.typography.body2, - ) - - Spacer(modifier = modifier.width(screenWidthDp(8.dp))) - - Text( - text = "(${uiState.imageCount}/1)", - color = ByeBooTheme.colors.gray400, - style = ByeBooTheme.typography.body5, - ) - } - - Spacer(modifier = modifier.height(screenHeightDp(8.dp))) - } - - item { - QuestPhotoPicker( - imageUrl = displayImageUri, - onImageClick = { url -> - onUpdateSelectedImage(url) - }, - ) - - Spacer(modifier = modifier.height(screenHeightDp(16.dp))) - } - - item { - Row(verticalAlignment = Alignment.CenterVertically) { - MiddleTag( - middleTagType = MiddleTagType.QUEST_OPTIONAL, - text = "선택", - textStyle = ByeBooTheme.typography.cap1, - ) - - Spacer(modifier = modifier.width(screenWidthDp(8.dp))) - - Text( - text = "생각 적기", - color = ByeBooTheme.colors.gray50, - style = ByeBooTheme.typography.body2, - ) - } - - Spacer(modifier = modifier.height(screenHeightDp(8.dp))) - } - - item { - Column { - QuestTextField( - questWritingState = uiState.contentState, - value = uiState.questAnswer, - onValueChange = { - if (it.length <= 200) { - onUpdateContent(it) - } - }, - placeholder = "꼭 적지 않아도 괜찮지만, 글로 정리해 보면 스스로에게 한 걸음 더 가까워질 수 있어요.", - isQuestion = false, - onFocusChanged = { - isFocused.value = it - }, - modifier = - modifier - .fillMaxWidth() - .bringIntoViewRequester(bringIntoViewRequester), - ) - } - } - - item { - Spacer(modifier = modifier.height(screenHeightDp(24.dp))) - - ByeBooActivationButton( - buttonDisableColor = ByeBooTheme.colors.whiteAlpha5, - buttonText = "완료하기", - buttonDisableTextColor = ByeBooTheme.colors.gray300, - onClick = { - onClickCompleteButton(context) - onUpdateSelectedImage(uiState.selectedImageUri) - }, - isEnabled = uiState.isCompleteButtonEnabled, - ) - - Spacer(modifier = Modifier.height(screenHeightDp(10.dp))) - } - } - } - - ByeBooBottomSheet( - selectedEmotion = uiState.selectedEmotion, - navigateButton = { navigateButton(context) }, - showBottomSheet = uiState.showBottomSheet, - onDismiss = onBottomSheetDismiss, - onEmotionSelected = onEmotionSelected, - isUploading = uiState.isUploading, - ) -} diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/complete/QuestBehaviorCompleteScreen.kt b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/complete/QuestBehaviorCompleteScreen.kt new file mode 100644 index 000000000..1a5a20d61 --- /dev/null +++ b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/complete/QuestBehaviorCompleteScreen.kt @@ -0,0 +1,239 @@ +package com.byeboo.app.presentation.quest.behavior.complete + +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.SubcomposeAsyncImage +import coil.request.CachePolicy +import coil.request.ImageRequest +import com.byeboo.app.R +import com.byeboo.app.core.designsystem.component.button.ByeBooButton +import com.byeboo.app.core.designsystem.component.text.ContentText +import com.byeboo.app.core.designsystem.event.LocalSnackBarTrigger +import com.byeboo.app.core.designsystem.type.EmotionChipType +import com.byeboo.app.core.designsystem.ui.theme.ByeBooTheme +import com.byeboo.app.core.util.findActivity +import com.byeboo.app.core.util.inAppReview +import com.byeboo.app.core.util.screenHeightDp +import com.byeboo.app.core.util.screenWidthDp +import com.byeboo.app.presentation.quest.component.card.QuestEmotionDescriptionCard +import com.byeboo.app.presentation.quest.component.text.QuestTitle + +@Composable +fun QuestBehaviorCompleteRoute( + navigateToQuest: () -> Unit, + navigateToOffboardingCompletedGuide: () -> Unit, + paddingValues: PaddingValues, + modifier: Modifier = Modifier, + viewModel: QuestBehaviorCompleteViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val showSnackBar = LocalSnackBarTrigger.current + val context = LocalContext.current + val activity = context.findActivity() + + val imageUri = + when { + uiState.selectedImageUri != null -> uiState.selectedImageUri + uiState.imageUrl.isNotBlank() -> uiState.imageUrl.toUri() + else -> null + } + + LaunchedEffect(Unit) { + viewModel.sideEffect.collect { effect -> + when (effect) { + is QuestBehaviorCompleteSideEffect.NavigateToQuest -> navigateToQuest() + is QuestBehaviorCompleteSideEffect.NavigateToOffboardingCompletedGuide -> navigateToOffboardingCompletedGuide() + is QuestBehaviorCompleteSideEffect.ShowInAppReview -> { + activity?.let { activity -> + inAppReview(activity) + } + } + + is QuestBehaviorCompleteSideEffect.ShowSnackBar -> showSnackBar(effect.message) + } + } + } + + BackHandler { viewModel.onCloseClicked() } + + QuestBehaviorCompleteScreen( + uiState = uiState, + paddingValues = paddingValues, + onCloseClick = viewModel::onCloseClicked, + imageUri = imageUri, + modifier = modifier, + ) +} + +@Composable +private fun QuestBehaviorCompleteScreen( + uiState: QuestBehaviorCompleteState, + paddingValues: PaddingValues, + onCloseClick: () -> Unit, + imageUri: Uri?, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(ByeBooTheme.colors.background) + .padding( + top = paddingValues.calculateTopPadding() + screenHeightDp(43.dp), + bottom = paddingValues.calculateBottomPadding(), + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = screenWidthDp(24.dp)), + horizontalArrangement = Arrangement.End, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_cancel), + contentDescription = "back button", + tint = ByeBooTheme.colors.white, + modifier = modifier.clickable(onClick = onCloseClick), + ) + } + + Spacer(modifier = modifier.height(screenHeightDp(16.dp))) + + LazyColumn( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = + PaddingValues( + start = screenWidthDp(24.dp), + end = screenWidthDp(24.dp), + bottom = screenHeightDp(24.dp), + ), + ) { + item { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(screenHeightDp(20.dp)), + ) { + QuestTitle( + stepNumber = uiState.stepNumber, + questNumber = uiState.questNumber, + createdAt = uiState.createdAt, + questQuestion = uiState.question, + ) + } + } + + item { + Column( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)), + ) { + if (imageUri != null) { + SubcomposeAsyncImage( + modifier = + Modifier + .fillMaxWidth(), + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageUri) + .memoryCachePolicy(CachePolicy.DISABLED) + .diskCachePolicy(CachePolicy.DISABLED) + .build(), + contentDescription = "uploaded image", + contentScale = ContentScale.Crop, + loading = { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + }, + ) + } + } + if (uiState.questAnswer.isNotBlank()) { + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + ContentText(uiState.questAnswer) + } + } + + item { + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + QuestEmotionDescriptionContent( + questEmotionDescription = uiState.emotionDescription, + emotionType = uiState.selectedEmotion, + ) + } + + item { + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + ByeBooButton( + buttonText = "보리에게 답장 받기", + buttonTextColor = ByeBooTheme.colors.white, + buttonStyle = ByeBooTheme.typography.body2, + buttonBackgroundColor = ByeBooTheme.colors.primary300, + onClick = { /*Todo: ai 버튼 연결 */ }, + ) + } + } + } +} + +@Composable +private fun QuestEmotionDescriptionContent( + questEmotionDescription: String, + emotionType: EmotionChipType, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + QuestEmotionDescriptionCard( + questEmotionDescription = questEmotionDescription, + emotionType = emotionType, + ) + + Spacer(modifier = Modifier.height(screenHeightDp(24.dp))) + } +} diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorCompleteState.kt b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/complete/QuestBehaviorCompleteState.kt similarity index 86% rename from app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorCompleteState.kt rename to app/src/main/java/com/byeboo/app/presentation/quest/behavior/complete/QuestBehaviorCompleteState.kt index cbef160ee..695ce4379 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorCompleteState.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/complete/QuestBehaviorCompleteState.kt @@ -1,4 +1,4 @@ -package com.byeboo.app.presentation.quest.behavior +package com.byeboo.app.presentation.quest.behavior.complete import android.net.Uri import com.byeboo.app.core.designsystem.type.EmotionChipType @@ -16,7 +16,7 @@ data class QuestBehaviorCompleteState( val imageUrl: String = "", val selectedImageUri: Uri? = null, val emotionDescription: String = "", - val selectedEmotion: EmotionChipType? = EmotionChipType.EMOTION_NEUTRAL, + val selectedEmotion: EmotionChipType = EmotionChipType.EMOTION_NEUTRAL, ) sealed interface QuestBehaviorCompleteSideEffect { diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorCompleteViewModel.kt b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/complete/QuestBehaviorCompleteViewModel.kt similarity index 98% rename from app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorCompleteViewModel.kt rename to app/src/main/java/com/byeboo/app/presentation/quest/behavior/complete/QuestBehaviorCompleteViewModel.kt index 87be868ac..e0ba30ff8 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorCompleteViewModel.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/complete/QuestBehaviorCompleteViewModel.kt @@ -1,4 +1,4 @@ -package com.byeboo.app.presentation.quest.behavior +package com.byeboo.app.presentation.quest.behavior.complete import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/component/QuestPhotoPicker.kt b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/component/QuestPhotoPicker.kt index 79444f6d6..a208a4c8c 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/component/QuestPhotoPicker.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/component/QuestPhotoPicker.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -22,7 +22,6 @@ import coil.compose.AsyncImage import com.byeboo.app.R import com.byeboo.app.core.designsystem.ui.theme.ByeBooTheme import com.byeboo.app.core.util.noRippleClickable -import com.byeboo.app.core.util.screenWidthDp @Composable internal fun QuestPhotoPicker( @@ -43,7 +42,7 @@ internal fun QuestPhotoPicker( Box( modifier = modifier - .width(screenWidthDp(96.dp)) + .fillMaxWidth() .aspectRatio(1f) .clip(RoundedCornerShape(12.dp)) .background(color = ByeBooTheme.colors.whiteAlpha5), @@ -52,6 +51,7 @@ internal fun QuestPhotoPicker( imageUrl = imageUrl, isUploaded = uploadedImage, onImageClick = { photoPickerLauncher.launch("image/*") }, + modifier = Modifier.fillMaxSize(), ) } } @@ -66,8 +66,6 @@ private fun ImageUploadButton( Box( modifier = modifier - .width(screenWidthDp(96.dp)) - .aspectRatio(1f) .clip(RoundedCornerShape(12.dp)) .noRippleClickable { onImageClick() }, contentAlignment = Alignment.Center, @@ -88,7 +86,7 @@ private fun ImageUploadButton( Icon( imageVector = ImageVector.vectorResource(id = R.drawable.ic_plus), contentDescription = null, - tint = ByeBooTheme.colors.primary300, + tint = ByeBooTheme.colors.gray500, ) } } diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/navigation/QuestBehaviorNavigation.kt b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/navigation/QuestBehaviorNavigation.kt index 1574bbed0..baf350378 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/navigation/QuestBehaviorNavigation.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/navigation/QuestBehaviorNavigation.kt @@ -7,10 +7,10 @@ import androidx.navigation.NavOptions import androidx.navigation.compose.composable import com.byeboo.app.core.model.quest.QuestType import com.byeboo.app.core.util.routeNavigation -import com.byeboo.app.presentation.quest.behavior.QuestBehaviorCompleteRoute -import com.byeboo.app.presentation.quest.behavior.QuestBehaviorWritingRoute +import com.byeboo.app.presentation.quest.behavior.complete.QuestBehaviorCompleteRoute import com.byeboo.app.presentation.quest.behavior.navigation.QuestBehavior.QuestBehaviorComplete import com.byeboo.app.presentation.quest.behavior.navigation.QuestBehavior.QuestBehaviorWriting +import com.byeboo.app.presentation.quest.behavior.writing.QuestBehaviorWritingRoute fun NavController.navigateToQuestBehavior( questId: Long, diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorState.kt b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/writing/QuestBehaviorState.kt similarity index 77% rename from app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorState.kt rename to app/src/main/java/com/byeboo/app/presentation/quest/behavior/writing/QuestBehaviorState.kt index 3ee0773e2..06b21a530 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorState.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/writing/QuestBehaviorState.kt @@ -1,9 +1,10 @@ -package com.byeboo.app.presentation.quest.behavior +package com.byeboo.app.presentation.quest.behavior.writing import android.net.Uri import com.byeboo.app.core.designsystem.type.EmotionChipType import com.byeboo.app.core.model.quest.QuestType import com.byeboo.app.domain.model.quest.QuestWritingState +import java.time.LocalDate data class QuestBehaviorState( val stepNumber: Long = 0, @@ -13,7 +14,7 @@ data class QuestBehaviorState( val question: String = "", val imageCount: Int = 0, val createdAt: String = - java.time.LocalDate + LocalDate .now() .toString(), val questAnswer: String = "", @@ -30,10 +31,23 @@ data class QuestBehaviorState( val isUploading: Boolean = false, val isEditMode: Boolean = false, val originalAnswer: String = "", - val isCompleteButtonEnabled: Boolean = false, - val hasAnswerChanged: Boolean = false, val fromOffboarding: Boolean = false, -) + val showCompleteModal: Boolean = false, +) { + val hasAnswerChanged: Boolean + get() = questAnswer != originalAnswer + + val isCompleteButtonEnabled: Boolean get() { + val hasImage = imageCount > 0 + + return if (isEditMode) { + val imageChanged = selectedImageUri != null + hasAnswerChanged || imageChanged + } else { + hasImage + } + } +} sealed interface QuestBehaviorSideEffect { data object NavigateToQuest : QuestBehaviorSideEffect diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorViewModel.kt b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/writing/QuestBehaviorViewModel.kt similarity index 74% rename from app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorViewModel.kt rename to app/src/main/java/com/byeboo/app/presentation/quest/behavior/writing/QuestBehaviorViewModel.kt index 8010a183b..366b2bea0 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/QuestBehaviorViewModel.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/writing/QuestBehaviorViewModel.kt @@ -1,4 +1,4 @@ -package com.byeboo.app.presentation.quest.behavior +package com.byeboo.app.presentation.quest.behavior.writing import android.content.Context import android.net.Uri @@ -46,9 +46,12 @@ class QuestBehaviorViewModel checkNotNull( savedStateHandle.toRoute().questId, ) - private val isEditModeArg: Boolean = savedStateHandle.toRoute().isEditMode - private val fromOffboardingArg: Boolean = savedStateHandle.toRoute().fromOffboarding - private val imageKeyArg: String? = savedStateHandle.toRoute().imageKey + private val isEditModeArg: Boolean = + savedStateHandle.toRoute().isEditMode + private val fromOffboardingArg: Boolean = + savedStateHandle.toRoute().fromOffboarding + private val imageKeyArg: String? = + savedStateHandle.toRoute().imageKey private val _uiState = MutableStateFlow( @@ -114,7 +117,15 @@ class QuestBehaviorViewModel } } - fun uploadImage(context: Context) { + fun onCompleteClicked(context: Context) { + if (uiState.value.isEditMode) { + onSaveEditClicked(context) + } else { + openBottomSheet() + } + } + + fun onSaveClicked(context: Context) { viewModelScope.launch { _uiState.update { it.copy(isUploading = true) } @@ -123,8 +134,6 @@ class QuestBehaviorViewModel val questId = state.questId val answer = state.questAnswer val emotion = state.selectedEmotion?.toData().orEmpty() - val isEditMode = state.isEditMode - val fromOffboarding = state.fromOffboarding runCatching { val inputStream = context.contentResolver.openInputStream(imageUrl) @@ -139,66 +148,112 @@ class QuestBehaviorViewModel questId = questId, answer = answer, emotion = emotion, - isEditMode = isEditMode, + isEditMode = false, ).getOrThrow() }.onSuccess { - if (isEditMode) { - mixpanelUtil.trackEvent( - eventName = "quest_edit", - properties = - mapOf( - "quest_end_at" to getFormattedDate(), - "quest_number" to questId, - "quest_type" to "행동형", - ), - ) - } else { - mixpanelUtil.trackEvent( - eventName = "quest_success", - properties = - mapOf( - "quest_end_at" to getFormattedDate(), - "quest_number" to questId, - "quest_type" to "행동형", - "after_emotion_type" to emotion, - ), + mixpanelUtil.trackEvent( + eventName = "quest_success", + properties = + mapOf( + "quest_end_at" to getFormattedDate(), + "quest_number" to questId, + "quest_type" to "행동형", + "after_emotion_type" to emotion, + ), + ) + _uiState.update { + it.copy( + showBottomSheet = false, + showCompleteModal = true, ) } + }.onFailure { + _sideEffect.emit( + QuestBehaviorSideEffect.ShowSnackBar("서버에 연결할 수 없습니다. 잠시 후 시도해 주세요."), + ) + } + } + } + + private fun onSaveEditClicked(context: Context) { + if (uiState.value.selectedImageUri == null) { + uploadWithoutImageChange() + } else { + uploadEditedImage(context) + } + } + + fun onCompleteModalTimeout() { + val questId = _uiState.value.questId + + _uiState.update { it.copy(showBottomSheet = false) } + + viewModelScope.launch { + _sideEffect.emit(QuestBehaviorSideEffect.NavigateToQuestBehaviorComplete(questId)) + } + } + + fun uploadEditedImage(context: Context) { + viewModelScope.launch { + _uiState.update { it.copy(isUploading = true) } + + val state = _uiState.value + val imageUrl = state.selectedImageUri ?: return@launch + val questId = state.questId + val answer = state.questAnswer + val emotion = state.selectedEmotion?.toData().orEmpty() + val fromOffboarding = state.fromOffboarding + + runCatching { + val inputStream = context.contentResolver.openInputStream(imageUrl) + val imageBytes = inputStream?.readBytes() ?: error("이미지 파일을 읽을 수 없습니다.") + val contentType = context.contentResolver.getType(imageUrl).toString() + val imageKey = UUID.randomUUID().toString() + + uploadImageUseCase( + imageBytes = imageBytes, + contentType = contentType, + imageKey = imageKey, + questId = questId, + answer = answer, + emotion = emotion, + isEditMode = true, + ).getOrThrow() + }.onSuccess { + mixpanelUtil.trackEvent( + eventName = "quest_edit", + properties = + mapOf( + "quest_end_at" to getFormattedDate(), + "quest_number" to questId, + "quest_type" to "행동형", + ), + ) + questRecordedDetailRepository.getQuestRecordedDetail(questId) _sideEffect.emit( - if (isEditMode) { - if (fromOffboarding) { - QuestBehaviorSideEffect.NavigateUp - } else { - QuestBehaviorSideEffect.NavigateToQuestReview(questId) - } + if (fromOffboarding) { + QuestBehaviorSideEffect.NavigateUp } else { - QuestBehaviorSideEffect.NavigateToQuestBehaviorComplete(questId) + QuestBehaviorSideEffect.NavigateToQuestReview(questId) }, ) _sideEffect.emit(QuestBehaviorSideEffect.CompleteAndClear(questId)) - closeBottomSheet() - }.onFailure { e -> + }.onFailure { _sideEffect.emit( QuestBehaviorSideEffect.ShowSnackBar("서버에 연결할 수 없습니다. 잠시 후 시도해 주세요."), ) } - _uiState.update { it.copy(isUploading = false) } } } fun updateSelectedImage(uri: Uri?) { _uiState.update { prev -> - val updated = - prev.copy( - selectedImageUri = uri, - imageCount = if (uri != null) 1 else 0, - ) - - updated.copy( - isCompleteButtonEnabled = completeButtonEnabled(updated), + prev.copy( + selectedImageUri = uri, + imageCount = if (uri != null) 1 else 0, ) } } @@ -212,16 +267,9 @@ class QuestBehaviorViewModel } _uiState.update { prev -> - val hasAnswerChanged = text != prev.originalAnswer - val updated = - prev.copy( - questAnswer = text, - contentState = contentState, - hasAnswerChanged = hasAnswerChanged, - ) - - updated.copy( - isCompleteButtonEnabled = completeButtonEnabled(updated), + prev.copy( + questAnswer = text, + contentState = contentState, ) } } @@ -276,29 +324,6 @@ class QuestBehaviorViewModel } } - fun onClickCompleteButton(context: Context) { - if (uiState.value.isEditMode) { - if (uiState.value.selectedImageUri == null) { - uploadWithoutImageChange() - } else { - uploadImage(context) - } - } else { - openBottomSheet() - } - } - - private fun completeButtonEnabled(state: QuestBehaviorState): Boolean { - val hasImage = state.imageCount > 0 - - return if (state.isEditMode) { - val imageChanged = state.selectedImageUri != null - state.hasAnswerChanged || imageChanged - } else { - hasImage - } - } - private fun uploadWithoutImageChange() { viewModelScope.launch { val state = uiState.value diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/behavior/writing/QuestBehaviorWritingScreen.kt b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/writing/QuestBehaviorWritingScreen.kt new file mode 100644 index 000000000..b07fa5880 --- /dev/null +++ b/app/src/main/java/com/byeboo/app/presentation/quest/behavior/writing/QuestBehaviorWritingScreen.kt @@ -0,0 +1,355 @@ +package com.byeboo.app.presentation.quest.behavior.writing + +import QuestPhotoPicker +import android.content.Context +import android.net.Uri +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.input.key.onPreInterceptKeyBeforeSoftKeyboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.byeboo.app.core.designsystem.component.tag.MiddleTag +import com.byeboo.app.core.designsystem.event.LocalSnackBarTrigger +import com.byeboo.app.core.designsystem.type.EmotionChipType +import com.byeboo.app.core.designsystem.type.MiddleTagType +import com.byeboo.app.core.designsystem.ui.theme.ByeBooTheme +import com.byeboo.app.core.model.quest.QuestType +import com.byeboo.app.core.util.addFocusCleaner +import com.byeboo.app.core.util.screenHeightDp +import com.byeboo.app.core.util.screenWidthDp +import com.byeboo.app.presentation.quest.component.bottomsheet.ByeBooBottomSheet +import com.byeboo.app.presentation.quest.component.card.QuestCompleteDialog +import com.byeboo.app.presentation.quest.component.modal.QuestQuitModal +import com.byeboo.app.presentation.quest.component.text.QuestWritingFooter +import com.byeboo.app.presentation.quest.component.text.QuestWritingTitle +import com.byeboo.app.presentation.quest.component.text.textfield.QuestTextField +import com.byeboo.app.presentation.quest.component.topbar.QuestWritingTopBar +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuestBehaviorWritingRoute( + navigateToQuest: () -> Unit, + navigateToQuestTip: (Long, QuestType) -> Unit, + navigateToQuestBehaviorComplete: (Long) -> Unit, + navigateToQuestReview: (Long) -> Unit, + navigateUp: () -> Unit, + paddingValues: PaddingValues, + modifier: Modifier = Modifier, + viewModel: QuestBehaviorViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val showSnackBar = LocalSnackBarTrigger.current + + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { effect -> + when (effect) { + is QuestBehaviorSideEffect.NavigateToQuest -> navigateToQuest() + is QuestBehaviorSideEffect.NavigateToQuestTip -> + navigateToQuestTip( + effect.questId, + effect.questType, + ) + + is QuestBehaviorSideEffect.NavigateToQuestBehaviorComplete -> + navigateToQuestBehaviorComplete( + effect.questId, + ) + + is QuestBehaviorSideEffect.CompleteAndClear -> viewModel.clearQuestInput() + is QuestBehaviorSideEffect.NavigateToQuestReview -> + navigateToQuestReview( + effect.questId, + ) + + is QuestBehaviorSideEffect.NavigateUp -> navigateUp() + is QuestBehaviorSideEffect.ShowSnackBar -> showSnackBar(effect.message) + } + } + } + + if (uiState.showQuitModal) { + QuestQuitModal( + onDismissRequest = viewModel::onDismissModal, + stayButton = viewModel::onDismissModal, + quitButton = { + viewModel.onDismissModal() + viewModel.onQuitClicked() + }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = screenWidthDp(48.dp)), + ) + } + + if (uiState.showCompleteModal) { + QuestCompleteDialog( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) + + LaunchedEffect(Unit) { + delay(2000L) + viewModel.onCompleteModalTimeout() + } + } + + BackHandler { viewModel.onBackClicked() } + + QuestBehaviorWritingScreen( + uiState = uiState, + paddingValues = paddingValues, + onBackClick = viewModel::onBackClicked, + onTipClick = viewModel::onTipClicked, + onUpdateSelectedImage = viewModel::updateSelectedImage, + onUpdateContent = viewModel::updateContent, + onSaveClick = viewModel::onSaveClicked, + onCompleteClick = viewModel::onCompleteClicked, + onBottomSheetDismiss = viewModel::closeBottomSheet, + onEmotionSelected = { selectedEmotion -> viewModel.updateSelectedEmotion(selectedEmotion) }, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun QuestBehaviorWritingScreen( + uiState: QuestBehaviorState, + paddingValues: PaddingValues, + onBackClick: () -> Unit, + onTipClick: () -> Unit, + onUpdateSelectedImage: (Uri?) -> Unit, + onCompleteClick: (Context) -> Unit, + onUpdateContent: (String) -> Unit, + onSaveClick: (Context) -> Unit, + onBottomSheetDismiss: () -> Unit, + onEmotionSelected: (EmotionChipType?) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val isFocused = remember { mutableStateOf(false) } + val displayImageUri: Uri? = + uiState.selectedImageUri + ?: uiState.imageUrl.takeIf { it.isNotBlank() }?.toUri() + + val scrollState = rememberScrollState() + + val density = LocalDensity.current + val isImeVisible = WindowInsets.ime.getBottom(density) > 0 + + Column( + modifier = + modifier + .fillMaxSize() + .background(ByeBooTheme.colors.background) + .onPreInterceptKeyBeforeSoftKeyboard { event -> + if (event.key.nativeKeyCode == KeyEvent.KEYCODE_BACK) { + focusManager.clearFocus(force = true) + isFocused.value = false + true + } else { + false + } + }.addFocusCleaner(focusManager) + .imePadding() + .padding( + top = paddingValues.calculateTopPadding() + screenHeightDp(43.dp), + bottom = if (isImeVisible) 0.dp else paddingValues.calculateBottomPadding(), + ), + ) { + QuestWritingTopBar( + isEnabled = uiState.isCompleteButtonEnabled, + onBackClick = onBackClick, + onCompleteClick = { + onCompleteClick(context) + onUpdateSelectedImage(uiState.selectedImageUri) + }, + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(scrollState) + .padding(horizontal = screenWidthDp(24.dp)), + ) { + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + QuestWritingTitle( + questNumber = uiState.questNumber, + question = uiState.question, + onTipClick = onTipClick, + ) + + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + HorizontalDivider( + thickness = 1.dp, + color = ByeBooTheme.colors.gray800, + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + QuestPhotoSection( + imageCount = uiState.imageCount, + displayImageUri = displayImageUri, + onUpdateSelectedImage = onUpdateSelectedImage, + ) + + Spacer(modifier = modifier.height(screenHeightDp(20.dp))) + + QuestWritingSection( + questAnswer = uiState.questAnswer, + onFocusChanged = { isFocused.value = it }, + onUpdateContent = onUpdateContent, + scrollState = scrollState, + ) + + Spacer(modifier = Modifier.height(screenHeightDp(32.dp))) + + QuestWritingFooter( + currentCharCount = uiState.questAnswer.length, + isPhotoQuestion = true, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = screenHeightDp(14.dp)), + ) + } + } + + ByeBooBottomSheet( + selectedEmotion = uiState.selectedEmotion, + navigateButton = { onSaveClick(context) }, + showBottomSheet = uiState.showBottomSheet, + onDismiss = onBottomSheetDismiss, + onEmotionSelected = onEmotionSelected, + isUploading = uiState.isUploading, + ) +} + +@Composable +private fun QuestPhotoSection( + imageCount: Int, + displayImageUri: Uri?, + onUpdateSelectedImage: (Uri?) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(screenHeightDp(12.dp)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(screenWidthDp(8.dp)), + ) { + MiddleTag( + middleTagType = MiddleTagType.QUEST_ESSENTIAL, + text = "필수", + textStyle = ByeBooTheme.typography.cap1, + ) + + Text( + text = "사진 첨부", + color = ByeBooTheme.colors.gray50, + style = ByeBooTheme.typography.body2, + ) + + Text( + text = "($imageCount/1)", + color = ByeBooTheme.colors.gray400, + style = ByeBooTheme.typography.body6, + ) + } + + QuestPhotoPicker( + imageUrl = displayImageUri, + onImageClick = { url -> + onUpdateSelectedImage(url) + }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun QuestWritingSection( + questAnswer: String, + onUpdateContent: (String) -> Unit, + onFocusChanged: (Boolean) -> Unit, + scrollState: ScrollState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(screenHeightDp(8.dp)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(screenWidthDp(8.dp)), + ) { + MiddleTag( + middleTagType = MiddleTagType.QUEST_OPTIONAL, + text = "선택", + textStyle = ByeBooTheme.typography.cap1, + ) + + Text( + text = "생각 적기", + color = ByeBooTheme.colors.gray50, + style = ByeBooTheme.typography.body2, + ) + } + + QuestTextField( + value = questAnswer, + onValueChange = { + if (it.length <= 200) { + onUpdateContent(it) + } + }, + placeholder = "꼭 적지 않아도 괜찮지만, 글로 정리해 보면 스스로에게 한 걸음 더 가까워질 수 있어요.", + onFocusChanged = onFocusChanged, + scrollState = scrollState, + ) + } +} diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/common/navigation/QuestCommonNavigation.kt b/app/src/main/java/com/byeboo/app/presentation/quest/common/navigation/QuestCommonNavigation.kt new file mode 100644 index 000000000..c7e7af0a4 --- /dev/null +++ b/app/src/main/java/com/byeboo/app/presentation/quest/common/navigation/QuestCommonNavigation.kt @@ -0,0 +1,48 @@ +package com.byeboo.app.presentation.quest.common.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.byeboo.app.core.util.routeNavigation +import com.byeboo.app.presentation.quest.common.navigation.QuestCommonRoute.QuestCommonComplete +import com.byeboo.app.presentation.quest.common.navigation.QuestCommonRoute.QuestCommonWriting +import com.byeboo.app.presentation.quest.common.writing.QuestCommonRoute + +fun NavController.navigateToQuestCommon( + questId: Long, + navOptions: NavOptions? = null, +) { + navigate( + QuestCommonWriting( + questId = questId, + ), + navOptions, + ) +} + +fun NavController.navigateToQuestCommonComplete( + questId: Long, + navOptions: NavOptions? = null, +) { + navigate(QuestCommonComplete(questId), navOptions) +} + +fun NavGraphBuilder.questCommonGraph( + navigateToQuest: () -> Unit, + navigateToQuestCommonComplete: (Long) -> Unit, + navigateUp: () -> Unit, + paddingValues: PaddingValues, +) { + routeNavigation { + composable { + QuestCommonRoute( + navigateToQuest = navigateToQuest, + navigateToQuestCommonComplete = navigateToQuestCommonComplete, + navigateUp = navigateUp, + paddingValues = paddingValues, + ) + } + } +} diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/common/navigation/QuestCommonRoute.kt b/app/src/main/java/com/byeboo/app/presentation/quest/common/navigation/QuestCommonRoute.kt new file mode 100644 index 000000000..6cd59fe8c --- /dev/null +++ b/app/src/main/java/com/byeboo/app/presentation/quest/common/navigation/QuestCommonRoute.kt @@ -0,0 +1,17 @@ +package com.byeboo.app.presentation.quest.common.navigation + +import com.byeboo.app.core.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +sealed class QuestCommonRoute : Route { + @Serializable + data class QuestCommonWriting( + val questId: Long, + ) : QuestCommonRoute() + + @Serializable + data class QuestCommonComplete( + val questId: Long, + ) : QuestCommonRoute() +} diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/common/writing/QuestCommonScreen.kt b/app/src/main/java/com/byeboo/app/presentation/quest/common/writing/QuestCommonScreen.kt new file mode 100644 index 000000000..ea91fd4b9 --- /dev/null +++ b/app/src/main/java/com/byeboo/app/presentation/quest/common/writing/QuestCommonScreen.kt @@ -0,0 +1,148 @@ +package com.byeboo.app.presentation.quest.common.writing + +import android.view.KeyEvent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.input.key.onPreInterceptKeyBeforeSoftKeyboard +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.byeboo.app.core.designsystem.ui.theme.ByeBooTheme +import com.byeboo.app.core.util.addFocusCleaner +import com.byeboo.app.core.util.screenHeightDp +import com.byeboo.app.core.util.screenWidthDp +import com.byeboo.app.presentation.quest.component.text.QuestWritingFooter +import com.byeboo.app.presentation.quest.component.text.QuestWritingTitle +import com.byeboo.app.presentation.quest.component.text.textfield.QuestTextField +import com.byeboo.app.presentation.quest.component.topbar.QuestWritingTopBar + +@Composable +fun QuestCommonRoute( + navigateToQuest: () -> Unit, + navigateToQuestCommonComplete: (Long) -> Unit, + navigateUp: () -> Unit, + paddingValues: PaddingValues, + modifier: Modifier = Modifier, + viewModel: QuestCommonViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + QuestCommonScreen( + uiState = uiState, + paddingValues = paddingValues, + onBackClick = viewModel::onBackClicked, + onCompleteClick = viewModel::onCompleteClicked, + onUpdateContent = viewModel::updateContent, + modifier = modifier, + ) +} + +@Composable +private fun QuestCommonScreen( + uiState: QuestCommonState, + paddingValues: PaddingValues, + onBackClick: () -> Unit, + onCompleteClick: () -> Unit, + onUpdateContent: (Boolean, String) -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val isFocused = remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + val density = LocalDensity.current + val isImeVisible = WindowInsets.ime.getBottom(density) > 0 + + Column( + modifier = + modifier + .fillMaxSize() + .background(ByeBooTheme.colors.background) + .onPreInterceptKeyBeforeSoftKeyboard { event -> + if (event.key.nativeKeyCode == KeyEvent.KEYCODE_BACK) { + focusManager.clearFocus(force = true) + isFocused.value = false + true + } else { + false + } + }.addFocusCleaner(focusManager) + .imePadding() + .padding( + top = paddingValues.calculateTopPadding() + screenHeightDp(43.dp), + bottom = if (isImeVisible) 0.dp else paddingValues.calculateBottomPadding(), + ), + ) { + QuestWritingTopBar( + isEnabled = uiState.isCompleteButtonEnabled, + onBackClick = onBackClick, + onCompleteClick = onCompleteClick, + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f, false) + .verticalScroll(scrollState) + .padding(horizontal = screenWidthDp(24.dp)), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + QuestWritingTitle( + question = uiState.question, + ) + + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + HorizontalDivider( + thickness = 1.dp, + color = ByeBooTheme.colors.gray800, + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + QuestTextField( + value = uiState.questAnswer, + onValueChange = { + if (it.length <= 500) { + onUpdateContent(isFocused.value, it) + } + }, + placeholder = "글로 적다 보면, 스스로에게 한 걸음 더 가까워질 수 있어요.", + onFocusChanged = { + isFocused.value = it + }, + scrollState = scrollState, + ) + Spacer(modifier = Modifier.height(screenHeightDp(32.dp))) + } + + QuestWritingFooter( + currentCharCount = uiState.questAnswer.length, + isPhotoQuestion = false, + ) + } +} diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/common/writing/QuestCommonState.kt b/app/src/main/java/com/byeboo/app/presentation/quest/common/writing/QuestCommonState.kt new file mode 100644 index 000000000..6e9a165f1 --- /dev/null +++ b/app/src/main/java/com/byeboo/app/presentation/quest/common/writing/QuestCommonState.kt @@ -0,0 +1,26 @@ +package com.byeboo.app.presentation.quest.common.writing + +import androidx.compose.runtime.Immutable +import com.byeboo.app.domain.model.quest.QuestContentLengthValidator +import com.byeboo.app.domain.model.quest.QuestWritingState + +@Immutable +data class QuestCommonState( + val question: String = "", + val questAnswer: String = "", + val originalAnswer: String = "", + val isEditMode: Boolean = false, + val contentsState: QuestWritingState = QuestWritingState.Empty, +) { + val hasAnswerChanged: Boolean + get() = questAnswer != originalAnswer + + val isCompleteButtonEnabled: Boolean get() { + val isValid = QuestContentLengthValidator.validButton(questAnswer) + return if (isEditMode) isValid && hasAnswerChanged else isValid + } +} + +sealed interface QuestCommonSideEffect { + data object NavigateToQuest +} diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/common/writing/QuestCommonViewModel.kt b/app/src/main/java/com/byeboo/app/presentation/quest/common/writing/QuestCommonViewModel.kt new file mode 100644 index 000000000..2a32151d7 --- /dev/null +++ b/app/src/main/java/com/byeboo/app/presentation/quest/common/writing/QuestCommonViewModel.kt @@ -0,0 +1,43 @@ +package com.byeboo.app.presentation.quest.common.writing + +import androidx.lifecycle.ViewModel +import com.byeboo.app.domain.model.quest.QuestContentLengthValidator +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@HiltViewModel +class QuestCommonViewModel + @Inject + constructor() : ViewModel() { + private val _uiState = MutableStateFlow(QuestCommonState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _sideEffect = MutableSharedFlow() + val sideEffect: SharedFlow = _sideEffect.asSharedFlow() + + fun onBackClicked() { + } + + fun onCompleteClicked() { + } + + fun updateContent( + isFocused: Boolean, + questAnswer: String, + ) { + val contentState = QuestContentLengthValidator.validate(isFocused, questAnswer) + _uiState.update { prev -> + prev.copy( + questAnswer = questAnswer, + contentsState = contentState, + ) + } + } + } diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/component/card/QuestCompleteCard.kt b/app/src/main/java/com/byeboo/app/presentation/quest/component/card/QuestCompleteDialog.kt similarity index 80% rename from app/src/main/java/com/byeboo/app/presentation/quest/component/card/QuestCompleteCard.kt rename to app/src/main/java/com/byeboo/app/presentation/quest/component/card/QuestCompleteDialog.kt index c7d142d44..131a4d3f2 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/component/card/QuestCompleteCard.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/component/card/QuestCompleteDialog.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.animateLottieCompositionAsState @@ -23,7 +25,22 @@ import com.byeboo.app.core.util.screenHeightDp import com.byeboo.app.core.util.screenWidthDp @Composable -fun QuestCompleteCard(modifier: Modifier = Modifier) { +fun QuestCompleteDialog(modifier: Modifier = Modifier) { + Dialog( + onDismissRequest = {}, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) { + QuestCompleteCard(modifier = modifier) + } +} + +@Composable +private fun QuestCompleteCard(modifier: Modifier = Modifier) { val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.bori_congrats)) val progress by animateLottieCompositionAsState(composition = composition) @@ -58,6 +75,7 @@ fun QuestCompleteCard(modifier: Modifier = Modifier) { LottieAnimation( composition = composition, progress = progress, + modifier = Modifier.height(screenHeightDp(172.dp)), ) Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/component/text/QuestWritingFooter.kt b/app/src/main/java/com/byeboo/app/presentation/quest/component/text/QuestWritingFooter.kt new file mode 100644 index 000000000..45433cf54 --- /dev/null +++ b/app/src/main/java/com/byeboo/app/presentation/quest/component/text/QuestWritingFooter.kt @@ -0,0 +1,68 @@ +package com.byeboo.app.presentation.quest.component.text + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.byeboo.app.R +import com.byeboo.app.core.designsystem.ui.theme.ByeBooTheme +import com.byeboo.app.core.util.screenWidthDp + +@Composable +fun QuestWritingFooter( + currentCharCount: Int, + isPhotoQuestion: Boolean, + modifier: Modifier = Modifier, +) { + val maxCharCount = if (isPhotoQuestion) 200 else 500 + + Row( + modifier = + modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!isPhotoQuestion) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_caution), + contentDescription = null, + tint = Color.Unspecified, + ) + + Spacer(modifier = Modifier.width(screenWidthDp(3.dp))) + + Text( + text = "10글자 이상 작성해 주세요.", + style = ByeBooTheme.typography.cap2, + color = ByeBooTheme.colors.gray400, + textAlign = TextAlign.Start, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = + buildAnnotatedString { + append(text = currentCharCount.toString()) + + append(text = "/") + + append(text = maxCharCount.toString()) + }, + style = ByeBooTheme.typography.body6, + color = ByeBooTheme.colors.gray400, + ) + } +} diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/component/text/QuestWritingTitle.kt b/app/src/main/java/com/byeboo/app/presentation/quest/component/text/QuestWritingTitle.kt new file mode 100644 index 000000000..8d2476045 --- /dev/null +++ b/app/src/main/java/com/byeboo/app/presentation/quest/component/text/QuestWritingTitle.kt @@ -0,0 +1,65 @@ +package com.byeboo.app.presentation.quest.component.text + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.byeboo.app.core.designsystem.component.tag.MiddleTag +import com.byeboo.app.core.designsystem.type.MiddleTagType +import com.byeboo.app.core.designsystem.ui.theme.ByeBooTheme +import com.byeboo.app.core.util.screenHeightDp + +@Composable +fun QuestWritingTitle( + question: String, + modifier: Modifier = Modifier, + questNumber: Long? = null, + onTipClick: (() -> Unit)? = null, +) { + val questTitle = if (questNumber == null) "공통퀘스트" else "${questNumber}번째 퀘스트" + + Column( + modifier = modifier.fillMaxWidth(), + ) { + Text( + text = questTitle, + modifier = Modifier.fillMaxWidth(), + color = ByeBooTheme.colors.gray500, + textAlign = TextAlign.Center, + style = ByeBooTheme.typography.body6, + ) + + Spacer(modifier = Modifier.height(screenHeightDp(12.dp))) + + Text( + text = question, + modifier = Modifier.fillMaxWidth(), + color = ByeBooTheme.colors.gray50, + textAlign = TextAlign.Center, + style = ByeBooTheme.typography.head2, + ) + + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + if (onTipClick != null) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + MiddleTag( + middleTagType = MiddleTagType.QUEST_TIP, + textStyle = ByeBooTheme.typography.cap1, + modifier = Modifier.clickable(onClick = onTipClick), + ) + } + } + } +} diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/component/text/textfield/QuestTextField.kt b/app/src/main/java/com/byeboo/app/presentation/quest/component/text/textfield/QuestTextField.kt index a8b2aae99..3b19db548 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/component/text/textfield/QuestTextField.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/component/text/textfield/QuestTextField.kt @@ -1,177 +1,92 @@ package com.byeboo.app.presentation.quest.component.text.textfield -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.byeboo.app.core.designsystem.ui.theme.ByeBooTheme import com.byeboo.app.core.util.screenHeightDp -import com.byeboo.app.core.util.screenWidthDp -import com.byeboo.app.domain.model.quest.QuestWritingState @Composable fun QuestTextField( - questWritingState: QuestWritingState, value: String, onValueChange: (String) -> Unit, + scrollState: ScrollState, modifier: Modifier = Modifier, isEnabled: Boolean = true, placeholder: String = "", - isQuestion: Boolean = true, onFocusChanged: ((Boolean) -> Unit)? = null, ) { - val currentCharCount = value.length - val maxCharCount = if (isQuestion) 500 else 200 - val byebooColor = ByeBooTheme.colors - val focusState = remember { mutableStateOf(false) } - val textFieldRequester = remember { BringIntoViewRequester() } - - val textFieldBorderColor by remember(questWritingState, focusState.value, value) { - derivedStateOf { - if (focusState.value) { - when (questWritingState) { - QuestWritingState.Empty, QuestWritingState.Writing -> byebooColor.primary300 - QuestWritingState.OverLimit -> byebooColor.error300 - QuestWritingState.Ready -> Color.Transparent - } - } else { - Color.Transparent - } - } - } - - val textCountColor by remember { - derivedStateOf { - if (focusState.value) { - when (questWritingState) { - QuestWritingState.Empty, QuestWritingState.Writing -> byebooColor.primary300 - QuestWritingState.OverLimit -> byebooColor.error300 - QuestWritingState.Ready -> byebooColor.gray300 - } - } else { - byebooColor.gray300 - } - } - } - + val isFocused = remember { mutableStateOf(false) } + val lastLineBottom = remember { mutableStateOf(0) } val keyboardController = LocalSoftwareKeyboardController.current - val scrollState = rememberScrollState() val focusManager = LocalFocusManager.current - val lastLineBottom = remember { mutableStateOf(0) } - LaunchedEffect(value) { - scrollState.animateScrollTo(scrollState.maxValue) + LaunchedEffect(isFocused.value) { + if (isFocused.value) { + snapshotFlow { scrollState.maxValue } + .collect { + scrollState.scrollTo(it) + } + } } - Box( + BasicTextField( + value = value, + onValueChange = onValueChange, modifier = modifier - .fillMaxSize() - .clip(RoundedCornerShape(12.dp)) - .border( - width = 1.dp, - color = textFieldBorderColor, - shape = RoundedCornerShape(12.dp), - ).background(color = ByeBooTheme.colors.whiteAlpha5) - .padding(horizontal = screenWidthDp(24.dp), vertical = screenHeightDp(16.dp)) - .bringIntoViewRequester(textFieldRequester), - ) { - Column( - modifier = Modifier.fillMaxSize(), - ) { - BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = - Modifier - .fillMaxSize() - .height(screenHeightDp(275.dp)) - .verticalScroll(scrollState) - .onFocusChanged { focusStateChanged -> - focusState.value = focusStateChanged.isFocused - onFocusChanged?.invoke(focusStateChanged.isFocused) - }, - enabled = isEnabled, - textStyle = - ByeBooTheme.typography.body3.copy( - color = ByeBooTheme.colors.white, - ), - keyboardOptions = - KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done, - ), - keyboardActions = - KeyboardActions(onDone = { - keyboardController?.hide() - focusManager.clearFocus() - }), - cursorBrush = SolidColor(ByeBooTheme.colors.white), - decorationBox = { innerTextField -> - if (value.isEmpty() && !(focusState.value)) { - Text( - text = placeholder, - color = ByeBooTheme.colors.gray300, - style = ByeBooTheme.typography.body3, - ) - } - innerTextField() + .fillMaxWidth() + .defaultMinSize(minHeight = screenHeightDp(180.dp)) + .onFocusChanged { focusStateChanged -> + isFocused.value = focusStateChanged.isFocused + onFocusChanged?.invoke(focusStateChanged.isFocused) }, - onTextLayout = { layoutResult -> - lastLineBottom.value = - layoutResult.getLineBottom(layoutResult.lineCount - 1).toInt() - }, - ) - - Text( - text = - buildAnnotatedString { - append(text = "(") - - append(text = currentCharCount.toString()) - - append(text = "/") - - append(text = maxCharCount.toString()) - - append(text = ")") - }, - style = ByeBooTheme.typography.body6, - color = textCountColor, - textAlign = TextAlign.End, - modifier = Modifier.fillMaxWidth(), - ) - } - } + enabled = isEnabled, + textStyle = + ByeBooTheme.typography.body3.copy( + color = ByeBooTheme.colors.white, + ), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions(onDone = { + keyboardController?.hide() + focusManager.clearFocus() + }), + cursorBrush = SolidColor(ByeBooTheme.colors.white), + decorationBox = { innerTextField -> + if (value.isEmpty() && !(isFocused.value)) { + Text( + text = placeholder, + color = ByeBooTheme.colors.gray300, + style = ByeBooTheme.typography.body3, + ) + } + innerTextField() + }, + onTextLayout = { layoutResult -> + lastLineBottom.value = + layoutResult.getLineBottom(layoutResult.lineCount - 1).toInt() + }, + ) } diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/component/topbar/QuestWritingTopbar.kt b/app/src/main/java/com/byeboo/app/presentation/quest/component/topbar/QuestWritingTopbar.kt new file mode 100644 index 000000000..89647a19e --- /dev/null +++ b/app/src/main/java/com/byeboo/app/presentation/quest/component/topbar/QuestWritingTopbar.kt @@ -0,0 +1,53 @@ +package com.byeboo.app.presentation.quest.component.topbar + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.byeboo.app.R +import com.byeboo.app.core.designsystem.ui.theme.ByeBooTheme +import com.byeboo.app.core.util.screenWidthDp + +@Composable +fun QuestWritingTopBar( + isEnabled: Boolean, + onBackClick: () -> Unit, + onCompleteClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(horizontal = screenWidthDp(22.dp)), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_left), + contentDescription = "back button", + tint = ByeBooTheme.colors.white, + modifier = + Modifier + .clickable(onClick = onBackClick), + ) + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = "완료", + color = if (isEnabled) ByeBooTheme.colors.primary300 else ByeBooTheme.colors.gray600, + style = ByeBooTheme.typography.body2, + modifier = + Modifier + .clickable( + enabled = isEnabled, + onClick = onCompleteClick, + ), + ) + } +} diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/model/QuestState.kt b/app/src/main/java/com/byeboo/app/presentation/quest/model/QuestState.kt index b06bd5d92..e3ac5879e 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/model/QuestState.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/model/QuestState.kt @@ -28,6 +28,10 @@ sealed interface QuestSideEffect { val questId: Long, ) : QuestSideEffect + data class NavigateToQuestCommon( + val questId: Long, + ) : QuestSideEffect + data class NavigateToQuestReview( val questId: Long, ) : QuestSideEffect diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/navigation/QuestNavigation.kt b/app/src/main/java/com/byeboo/app/presentation/quest/navigation/QuestNavigation.kt index bebdf92ab..8e5ce6926 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/navigation/QuestNavigation.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/navigation/QuestNavigation.kt @@ -9,6 +9,7 @@ import com.byeboo.app.core.model.quest.QuestType import com.byeboo.app.core.util.routeNavigation import com.byeboo.app.presentation.quest.QuestRoute import com.byeboo.app.presentation.quest.behavior.navigation.questBehaviorGraph +import com.byeboo.app.presentation.quest.common.navigation.questCommonGraph import com.byeboo.app.presentation.quest.record.navigation.questRecordGraph import com.byeboo.app.presentation.quest.review.QuestReviewRoute import com.byeboo.app.presentation.quest.start.QuestStartRoute @@ -46,6 +47,7 @@ fun NavGraphBuilder.questGraph( navigateToHome: () -> Unit, navigateToQuestRecording: (Long) -> Unit, navigateToQuestBehavior: (Long) -> Unit, + navigateToQuestCommon: (Long) -> Unit, navigateToQuestReview: (Long) -> Unit, navigateToOffboardingCompletedGuide: () -> Unit, navigateToQuestRecordingComplete: (Long) -> Unit, @@ -53,6 +55,7 @@ fun NavGraphBuilder.questGraph( navigateToQuestBehaviorEdit: (Long, Boolean, String) -> Unit, navigateToQuestTip: (Long, QuestType) -> Unit, navigateToQuestBehaviorComplete: (Long) -> Unit, + navigateToQuestCommonComplete: (Long) -> Unit, paddingValues: PaddingValues, ) { routeNavigation { @@ -69,6 +72,7 @@ fun NavGraphBuilder.questGraph( navigateToQuestTip = navigateToQuestTip, navigateToQuestRecording = navigateToQuestRecording, navigateToQuestBehavior = navigateToQuestBehavior, + navigateToQuestCommon = navigateToQuestCommon, navigateToQuestReview = navigateToQuestReview, paddingValues = paddingValues, ) @@ -109,5 +113,12 @@ fun NavGraphBuilder.questGraph( navigateUp = navigateUp, paddingValues = paddingValues, ) + + questCommonGraph( + navigateToQuest = navigateToQuest, + navigateToQuestCommonComplete = navigateToQuestCommonComplete, + navigateUp = navigateUp, + paddingValues = paddingValues, + ) } } diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingScreen.kt b/app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingScreen.kt deleted file mode 100644 index 4efe15d23..000000000 --- a/app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingScreen.kt +++ /dev/null @@ -1,321 +0,0 @@ -package com.byeboo.app.presentation.quest.record - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.nativeKeyCode -import androidx.compose.ui.input.key.onPreInterceptKeyBeforeSoftKeyboard -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.byeboo.app.R -import com.byeboo.app.core.designsystem.component.button.ByeBooActivationButton -import com.byeboo.app.core.designsystem.component.tag.MiddleTag -import com.byeboo.app.core.designsystem.component.tag.SmallTag -import com.byeboo.app.core.designsystem.event.LocalSnackBarTrigger -import com.byeboo.app.core.designsystem.type.EmotionChipType -import com.byeboo.app.core.designsystem.type.MiddleTagType -import com.byeboo.app.core.designsystem.ui.theme.ByeBooTheme -import com.byeboo.app.core.model.quest.QuestType -import com.byeboo.app.core.util.addFocusCleaner -import com.byeboo.app.core.util.screenHeightDp -import com.byeboo.app.core.util.screenWidthDp -import com.byeboo.app.presentation.quest.component.bottomsheet.ByeBooBottomSheet -import com.byeboo.app.presentation.quest.component.modal.QuestQuitModal -import com.byeboo.app.presentation.quest.component.text.textfield.QuestTextField -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest - -@Composable -fun QuestRecordingRoute( - navigateToQuest: () -> Unit, - navigateToQuestTip: (Long, QuestType) -> Unit, - navigateToQuestRecordingComplete: (Long) -> Unit, - navigateToQuestReview: (Long) -> Unit, - navigateUp: () -> Unit, - paddingValues: PaddingValues, - modifier: Modifier = Modifier, - viewModel: QuestRecordingViewModel = hiltViewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val showSnackBar = LocalSnackBarTrigger.current - - LaunchedEffect(Unit) { - viewModel.sideEffect.collectLatest { effect -> - when (effect) { - is QuestRecordingSideEffect.NavigateToQuest -> navigateToQuest() - is QuestRecordingSideEffect.NavigateToQuestTip -> - navigateToQuestTip( - effect.questId, - effect.questType, - ) - is QuestRecordingSideEffect.NavigateToQuestRecordingComplete -> - navigateToQuestRecordingComplete( - effect.questId, - ) - is QuestRecordingSideEffect.NavigateToQuestReview -> - navigateToQuestReview( - effect.questId, - ) - is QuestRecordingSideEffect.NavigateUp -> navigateUp() - is QuestRecordingSideEffect.ShowSnackBar -> showSnackBar(effect.message) - } - } - } - - if (uiState.showQuitModal) { - QuestQuitModal( - onDismissRequest = viewModel::onDismissModal, - stayButton = viewModel::onDismissModal, - quitButton = { - viewModel.onDismissModal() - viewModel.onQuitClicked() - }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = screenWidthDp(48.dp)), - ) - } - - BackHandler { viewModel.onBackClicked() } - - QuestRecordingScreen( - uiState = uiState, - paddingValues = paddingValues, - onBackClick = viewModel::onBackClicked, - onTipClick = viewModel::onTipClicked, - onClickCompleteButton = viewModel::onClickCompleteButton, - onUpdateContent = viewModel::updateContent, - onSaveClick = viewModel::onSaveClicked, - onBottomSheetDismiss = viewModel::closeBottomSheet, - onEmotionSelected = { selectedEmotion -> viewModel.updateSelectedEmotion(selectedEmotion) }, - modifier = modifier, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun QuestRecordingScreen( - uiState: QuestRecordingState, - paddingValues: PaddingValues, - onBackClick: () -> Unit, - onTipClick: () -> Unit, - onClickCompleteButton: () -> Unit, - onUpdateContent: (Boolean, String) -> Unit, - onSaveClick: () -> Unit, - onBottomSheetDismiss: () -> Unit, - onEmotionSelected: (EmotionChipType?) -> Unit, - modifier: Modifier = Modifier, -) { - val focusManager = LocalFocusManager.current - val bringIntoViewRequester = remember { BringIntoViewRequester() } - val isFocused = remember { mutableStateOf(false) } - - LaunchedEffect(isFocused.value) { - if (isFocused.value) { - delay(300) - bringIntoViewRequester.bringIntoView() - } - } - - Column( - modifier = - modifier - .fillMaxSize() - .background(ByeBooTheme.colors.background) - .onPreInterceptKeyBeforeSoftKeyboard { event -> - if (event.key.nativeKeyCode == android.view.KeyEvent.KEYCODE_BACK) { - focusManager.clearFocus(force = true) - isFocused.value = false - true - } else { - false - } - }.addFocusCleaner(focusManager) - .padding( - top = paddingValues.calculateTopPadding() + screenHeightDp(43.dp), - bottom = paddingValues.calculateBottomPadding(), - ), - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_left), - contentDescription = "back button", - tint = ByeBooTheme.colors.white, - modifier = - Modifier - .padding(start = screenWidthDp(24.dp)) - .align(Alignment.Start) - .clickable { onBackClick() }, - ) - - Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) - - LazyColumn( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = PaddingValues(screenWidthDp(24.dp)), - ) { - item { - Spacer(modifier = Modifier.height(screenHeightDp(10.dp))) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - SmallTag( - tagText = "STEP ${uiState.stepNumber}", - tagColor = ByeBooTheme.colors.gray500, - ) - - Spacer(modifier = Modifier.width(screenWidthDp(8.dp))) - - Text( - text = uiState.step, - style = ByeBooTheme.typography.body2, - color = ByeBooTheme.colors.gray500, - ) - } - } - - item { - Spacer(modifier = Modifier.height(screenHeightDp(12.dp))) - - Text( - text = "${uiState.questNumber}번째 퀘스트", - modifier = Modifier.fillMaxWidth(), - color = ByeBooTheme.colors.gray500, - style = ByeBooTheme.typography.body6, - textAlign = TextAlign.Center, - ) - } - - item { - Spacer(modifier = Modifier.height(screenHeightDp(12.dp))) - - Text( - text = uiState.question, - modifier = Modifier.fillMaxWidth(), - color = ByeBooTheme.colors.gray100, - style = ByeBooTheme.typography.head1, - textAlign = TextAlign.Center, - ) - } - - item { - Spacer(modifier = Modifier.height(screenHeightDp(25.dp))) - - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - MiddleTag( - middleTagType = MiddleTagType.QUEST_TIP, - text = "작성 TIP", - textStyle = ByeBooTheme.typography.cap1, - modifier = Modifier.clickable { onTipClick() }, - ) - } - } - - item { - Spacer(modifier = Modifier.height(screenHeightDp(24.dp))) - - Column( - modifier = - Modifier - .fillMaxWidth() - .bringIntoViewRequester(bringIntoViewRequester), - ) { - QuestTextField( - questWritingState = uiState.contentsState, - value = uiState.questAnswer, - onValueChange = { - if (it.length <= 500) { - onUpdateContent(isFocused.value, it) - } - }, - placeholder = "글로 적다 보면, 스스로에게 한 걸음 더 가까워질 수 있어요.", - onFocusChanged = { - isFocused.value = it - }, - ) - Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_caution), - contentDescription = null, - tint = Color.Unspecified, - ) - - Spacer(modifier = Modifier.width(screenWidthDp(3.dp))) - - Text( - text = "10글자 이상 작성해 주세요.", - style = ByeBooTheme.typography.cap2, - color = ByeBooTheme.colors.gray400, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Start, - ) - } - } - } - } - - Spacer(modifier = Modifier.weight(1f)) - - ByeBooActivationButton( - buttonDisableColor = ByeBooTheme.colors.whiteAlpha5, - buttonText = "완료하기", - buttonDisableTextColor = ByeBooTheme.colors.gray300, - onClick = onClickCompleteButton, - isEnabled = uiState.isCompleteButtonEnabled, - modifier = Modifier.padding(horizontal = screenWidthDp(24.dp)), - ) - - Spacer(modifier = Modifier.height(screenHeightDp(10.dp))) - } - - ByeBooBottomSheet( - selectedEmotion = uiState.selectedEmotion, - navigateButton = onSaveClick, - showBottomSheet = uiState.showBottomSheet, - onDismiss = onBottomSheetDismiss, - onEmotionSelected = onEmotionSelected, - ) -} diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingCompleteScreen.kt b/app/src/main/java/com/byeboo/app/presentation/quest/record/complete/QuestRecordingCompleteScreen.kt similarity index 51% rename from app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingCompleteScreen.kt rename to app/src/main/java/com/byeboo/app/presentation/quest/record/complete/QuestRecordingCompleteScreen.kt index aed469e07..17a98c98d 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingCompleteScreen.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/record/complete/QuestRecordingCompleteScreen.kt @@ -1,4 +1,4 @@ -package com.byeboo.app.presentation.quest.record +package com.byeboo.app.presentation.quest.record.complete import androidx.activity.compose.BackHandler import androidx.compose.foundation.background @@ -12,25 +12,23 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.byeboo.app.R -import com.byeboo.app.core.designsystem.component.tag.SmallTag +import com.byeboo.app.core.designsystem.component.button.ByeBooButton +import com.byeboo.app.core.designsystem.component.text.ContentText import com.byeboo.app.core.designsystem.event.LocalSnackBarTrigger import com.byeboo.app.core.designsystem.type.EmotionChipType import com.byeboo.app.core.designsystem.ui.theme.ByeBooTheme @@ -38,11 +36,8 @@ import com.byeboo.app.core.util.findActivity import com.byeboo.app.core.util.inAppReview import com.byeboo.app.core.util.screenHeightDp import com.byeboo.app.core.util.screenWidthDp -import com.byeboo.app.presentation.quest.component.card.QuestCompleteCard import com.byeboo.app.presentation.quest.component.card.QuestEmotionDescriptionCard -import com.byeboo.app.presentation.quest.component.text.CreatedText -import com.byeboo.app.presentation.quest.component.text.QuestContent -import com.byeboo.app.presentation.quest.component.type.QuestContentType +import com.byeboo.app.presentation.quest.component.text.QuestTitle @Composable fun QuestRecordingCompleteRoute( @@ -67,6 +62,7 @@ fun QuestRecordingCompleteRoute( inAppReview(activity) } } + is QuestRecordingCompleteSideEffect.ShowSnackBar -> showSnackBar(effect.message) } } @@ -89,11 +85,14 @@ private fun QuestRecordingCompleteScreen( onCloseClick: () -> Unit, modifier: Modifier = Modifier, ) { + val scrollState = rememberScrollState() + Column( modifier = modifier .fillMaxSize() .background(ByeBooTheme.colors.background) + .padding(horizontal = screenWidthDp(24.dp)) .padding( top = paddingValues.calculateTopPadding() + screenHeightDp(43.dp), bottom = paddingValues.calculateBottomPadding(), @@ -102,8 +101,7 @@ private fun QuestRecordingCompleteScreen( Row( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = screenWidthDp(24.dp)), + .fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { Icon( @@ -114,83 +112,41 @@ private fun QuestRecordingCompleteScreen( ) } - Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + Spacer(modifier = Modifier.height(screenHeightDp(24.dp))) - LazyColumn( - modifier = Modifier.fillMaxWidth(), + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(state = scrollState), horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = - PaddingValues( - start = screenWidthDp(24.dp), - top = screenHeightDp(8.dp), - end = screenWidthDp(24.dp), - bottom = screenHeightDp(24.dp), - ), + verticalArrangement = Arrangement.spacedBy(screenHeightDp(20.dp)), ) { - item { - QuestCompleteCard( - modifier = Modifier.fillMaxWidth(), - ) - } - - item { - Spacer(modifier = Modifier.height(screenHeightDp(32.dp))) - - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - SmallTag( - tagText = "STEP ${uiState.stepNumber}", - tagColor = ByeBooTheme.colors.gray500, - ) - - Spacer(modifier = Modifier.width(screenWidthDp(8.dp))) - - Text( - text = "${uiState.questNumber}번째 퀘스트", - style = ByeBooTheme.typography.body6, - color = ByeBooTheme.colors.gray500, - ) - } - - Spacer(modifier = Modifier.height(screenHeightDp(12.dp))) - - CreatedText(uiState.createdAt) - - Spacer(modifier = Modifier.height(screenHeightDp(12.dp))) - - Text( - text = uiState.question, - style = ByeBooTheme.typography.head1, - color = ByeBooTheme.colors.gray100, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - - Spacer(modifier = Modifier.height(screenHeightDp(24.dp))) - - QuestContent( - titleIcon = QuestContentType.THINKING, - titleText = "이렇게 생각했어요", - contentText = uiState.answer, - ) + QuestTitle( + stepNumber = uiState.stepNumber, + questNumber = uiState.questNumber, + createdAt = uiState.createdAt, + questQuestion = uiState.question, + ) - Spacer(modifier = Modifier.height(screenHeightDp(24.dp))) + ContentText( + text = uiState.answer, + ) - QuestEmotionDescriptionContent( - questEmotionDescription = uiState.emotionDescription, - emotionType = uiState.selectedEmotion, - ) - } - } + QuestEmotionDescriptionContent( + questEmotionDescription = uiState.emotionDescription, + emotionType = uiState.selectedEmotion, + ) } + + ByeBooButton( + buttonText = "보리에게 답장 받기", + buttonTextColor = ByeBooTheme.colors.white, + buttonStyle = ByeBooTheme.typography.body2, + buttonBackgroundColor = ByeBooTheme.colors.primary300, + onClick = { /*Todo: ai 버튼 연결 */ }, + ) } } @@ -203,26 +159,6 @@ private fun QuestEmotionDescriptionContent( Column( modifier = modifier.fillMaxWidth(), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_change), - contentDescription = "title icon", - tint = Color.Unspecified, - ) - - Spacer(modifier = Modifier.width(screenWidthDp(8.dp))) - - Text( - text = "퀘스트 완료 후, 이런 감정을 느꼈어요", - color = ByeBooTheme.colors.gray200, - style = ByeBooTheme.typography.body2, - ) - } - - Spacer(modifier = Modifier.height(screenHeightDp(12.dp))) - QuestEmotionDescriptionCard( questEmotionDescription = questEmotionDescription, emotionType = emotionType, diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingCompleteState.kt b/app/src/main/java/com/byeboo/app/presentation/quest/record/complete/QuestRecordingCompleteState.kt similarity index 89% rename from app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingCompleteState.kt rename to app/src/main/java/com/byeboo/app/presentation/quest/record/complete/QuestRecordingCompleteState.kt index e7e9f5c5c..dbe5a4026 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingCompleteState.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/record/complete/QuestRecordingCompleteState.kt @@ -1,7 +1,8 @@ -package com.byeboo.app.presentation.quest.record +package com.byeboo.app.presentation.quest.record.complete import androidx.compose.runtime.Immutable import com.byeboo.app.core.designsystem.type.EmotionChipType +import java.time.LocalDate @Immutable data class QuestRecordingCompleteState( @@ -9,7 +10,7 @@ data class QuestRecordingCompleteState( val stepNumber: Long = 0, val questNumber: Long = 0, val createdAt: String = - java.time.LocalDate + LocalDate .now() .toString(), val question: String = "", diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingCompleteViewModel.kt b/app/src/main/java/com/byeboo/app/presentation/quest/record/complete/QuestRecordingCompleteViewModel.kt similarity index 98% rename from app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingCompleteViewModel.kt rename to app/src/main/java/com/byeboo/app/presentation/quest/record/complete/QuestRecordingCompleteViewModel.kt index fca65dc38..5dc7b70ff 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingCompleteViewModel.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/record/complete/QuestRecordingCompleteViewModel.kt @@ -1,4 +1,4 @@ -package com.byeboo.app.presentation.quest.record +package com.byeboo.app.presentation.quest.record.complete import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/record/navigation/QuestRecordNavigation.kt b/app/src/main/java/com/byeboo/app/presentation/quest/record/navigation/QuestRecordNavigation.kt index e3fb99e22..2f5baa3bf 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/record/navigation/QuestRecordNavigation.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/record/navigation/QuestRecordNavigation.kt @@ -7,10 +7,10 @@ import androidx.navigation.NavOptions import androidx.navigation.compose.composable import com.byeboo.app.core.model.quest.QuestType import com.byeboo.app.core.util.routeNavigation -import com.byeboo.app.presentation.quest.record.QuestRecordingCompleteRoute -import com.byeboo.app.presentation.quest.record.QuestRecordingRoute -import com.byeboo.app.presentation.quest.record.navigation.QuestRecord.QuestRecording +import com.byeboo.app.presentation.quest.record.complete.QuestRecordingCompleteRoute import com.byeboo.app.presentation.quest.record.navigation.QuestRecord.QuestRecordingComplete +import com.byeboo.app.presentation.quest.record.navigation.QuestRecord.QuestRecordingWriting +import com.byeboo.app.presentation.quest.record.writing.QuestRecordingRoute fun NavController.navigateToQuestRecording( questId: Long, @@ -19,7 +19,7 @@ fun NavController.navigateToQuestRecording( navOptions: NavOptions? = null, ) { navigate( - QuestRecording( + QuestRecordingWriting( questId = questId, isEditMode = isEditMode, fromOffboarding = fromOffboarding, @@ -44,8 +44,8 @@ fun NavGraphBuilder.questRecordGraph( navigateUp: () -> Unit, paddingValues: PaddingValues, ) { - routeNavigation { - composable { + routeNavigation { + composable { QuestRecordingRoute( navigateToQuest = navigateToQuest, navigateToQuestTip = navigateToQuestTip, diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/record/navigation/QuestRecordRoute.kt b/app/src/main/java/com/byeboo/app/presentation/quest/record/navigation/QuestRecordRoute.kt index 443f6e6d6..3cce61fb2 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/record/navigation/QuestRecordRoute.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/record/navigation/QuestRecordRoute.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable @Serializable sealed class QuestRecord : Route { @Serializable - data class QuestRecording( + data class QuestRecordingWriting( val questId: Long, val isEditMode: Boolean, val fromOffboarding: Boolean, diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/record/writing/QuestRecordingScreen.kt b/app/src/main/java/com/byeboo/app/presentation/quest/record/writing/QuestRecordingScreen.kt new file mode 100644 index 000000000..891ea5cb2 --- /dev/null +++ b/app/src/main/java/com/byeboo/app/presentation/quest/record/writing/QuestRecordingScreen.kt @@ -0,0 +1,243 @@ +package com.byeboo.app.presentation.quest.record.writing + +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.input.key.onPreInterceptKeyBeforeSoftKeyboard +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.byeboo.app.core.designsystem.event.LocalSnackBarTrigger +import com.byeboo.app.core.designsystem.type.EmotionChipType +import com.byeboo.app.core.designsystem.ui.theme.ByeBooTheme +import com.byeboo.app.core.model.quest.QuestType +import com.byeboo.app.core.util.addFocusCleaner +import com.byeboo.app.core.util.screenHeightDp +import com.byeboo.app.core.util.screenWidthDp +import com.byeboo.app.presentation.quest.component.bottomsheet.ByeBooBottomSheet +import com.byeboo.app.presentation.quest.component.card.QuestCompleteDialog +import com.byeboo.app.presentation.quest.component.modal.QuestQuitModal +import com.byeboo.app.presentation.quest.component.text.QuestWritingFooter +import com.byeboo.app.presentation.quest.component.text.QuestWritingTitle +import com.byeboo.app.presentation.quest.component.text.textfield.QuestTextField +import com.byeboo.app.presentation.quest.component.topbar.QuestWritingTopBar +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun QuestRecordingRoute( + navigateToQuest: () -> Unit, + navigateToQuestTip: (Long, QuestType) -> Unit, + navigateToQuestRecordingComplete: (Long) -> Unit, + navigateToQuestReview: (Long) -> Unit, + navigateUp: () -> Unit, + paddingValues: PaddingValues, + modifier: Modifier = Modifier, + viewModel: QuestRecordingViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val showSnackBar = LocalSnackBarTrigger.current + + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { effect -> + when (effect) { + is QuestRecordingSideEffect.NavigateToQuest -> navigateToQuest() + is QuestRecordingSideEffect.NavigateToQuestTip -> + navigateToQuestTip( + effect.questId, + effect.questType, + ) + + is QuestRecordingSideEffect.NavigateToQuestRecordingComplete -> + navigateToQuestRecordingComplete( + effect.questId, + ) + + is QuestRecordingSideEffect.NavigateToQuestReview -> + navigateToQuestReview( + effect.questId, + ) + + is QuestRecordingSideEffect.NavigateUp -> navigateUp() + is QuestRecordingSideEffect.ShowSnackBar -> showSnackBar(effect.message) + } + } + } + + if (uiState.showQuitModal) { + QuestQuitModal( + onDismissRequest = viewModel::onDismissModal, + stayButton = viewModel::onDismissModal, + quitButton = { + viewModel.onDismissModal() + viewModel.onQuitClicked() + }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = screenWidthDp(48.dp)), + dialogProperties = DialogProperties(usePlatformDefaultWidth = false), + ) + } + + if (uiState.showCompleteModal) { + QuestCompleteDialog( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) + + LaunchedEffect(Unit) { + delay(2000L) + viewModel.onCompleteModalTimeout() + } + } + + BackHandler { viewModel.onBackClicked() } + + QuestRecordingScreen( + uiState = uiState, + paddingValues = paddingValues, + onBackClick = viewModel::onBackClicked, + onTipClick = viewModel::onTipClicked, + onCompleteClick = viewModel::onCompleteClicked, + onUpdateContent = viewModel::updateContent, + onSaveClick = viewModel::onSaveClicked, + onBottomSheetDismiss = viewModel::closeBottomSheet, + onEmotionSelected = { selectedEmotion -> viewModel.updateSelectedEmotion(selectedEmotion) }, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun QuestRecordingScreen( + uiState: QuestRecordingState, + paddingValues: PaddingValues, + onBackClick: () -> Unit, + onTipClick: () -> Unit, + onCompleteClick: () -> Unit, + onUpdateContent: (Boolean, String) -> Unit, + onSaveClick: () -> Unit, + onBottomSheetDismiss: () -> Unit, + onEmotionSelected: (EmotionChipType?) -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val isFocused = remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + val density = LocalDensity.current + val isImeVisible = WindowInsets.ime.getBottom(density) > 0 + + Column( + modifier = + modifier + .fillMaxSize() + .background(ByeBooTheme.colors.background) + .onPreInterceptKeyBeforeSoftKeyboard { event -> + if (event.key.nativeKeyCode == KeyEvent.KEYCODE_BACK) { + focusManager.clearFocus(force = true) + isFocused.value = false + true + } else { + false + } + }.addFocusCleaner(focusManager) + .imePadding() + .padding( + top = paddingValues.calculateTopPadding() + screenHeightDp(43.dp), + bottom = if (isImeVisible) 0.dp else paddingValues.calculateBottomPadding(), + ), + ) { + QuestWritingTopBar( + isEnabled = uiState.isCompleteButtonEnabled, + onBackClick = onBackClick, + onCompleteClick = onCompleteClick, + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f, false) + .verticalScroll(scrollState) + .padding(horizontal = screenWidthDp(24.dp)), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + QuestWritingTitle( + questNumber = uiState.questNumber, + question = uiState.question, + onTipClick = onTipClick, + ) + + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + HorizontalDivider( + thickness = 1.dp, + color = ByeBooTheme.colors.gray800, + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + QuestTextField( + value = uiState.questAnswer, + onValueChange = { + if (it.length <= 500) { + onUpdateContent(isFocused.value, it) + } + }, + placeholder = "글로 적다 보면, 스스로에게 한 걸음 더 가까워질 수 있어요.", + onFocusChanged = { + isFocused.value = it + }, + scrollState = scrollState, + ) + Spacer(modifier = Modifier.height(screenHeightDp(32.dp))) + } + + QuestWritingFooter( + currentCharCount = uiState.questAnswer.length, + isPhotoQuestion = false, + modifier = + Modifier + .padding(horizontal = screenWidthDp(24.dp)), + ) + } + + ByeBooBottomSheet( + selectedEmotion = uiState.selectedEmotion, + navigateButton = onSaveClick, + showBottomSheet = uiState.showBottomSheet, + onDismiss = onBottomSheetDismiss, + onEmotionSelected = onEmotionSelected, + ) +} diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingState.kt b/app/src/main/java/com/byeboo/app/presentation/quest/record/writing/QuestRecordingState.kt similarity index 74% rename from app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingState.kt rename to app/src/main/java/com/byeboo/app/presentation/quest/record/writing/QuestRecordingState.kt index cb4ff1bd3..79bcd437a 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingState.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/record/writing/QuestRecordingState.kt @@ -1,8 +1,9 @@ -package com.byeboo.app.presentation.quest.record +package com.byeboo.app.presentation.quest.record.writing import androidx.compose.runtime.Immutable import com.byeboo.app.core.designsystem.type.EmotionChipType import com.byeboo.app.core.model.quest.QuestType +import com.byeboo.app.domain.model.quest.QuestContentLengthValidator import com.byeboo.app.domain.model.quest.QuestWritingState @Immutable @@ -19,10 +20,17 @@ data class QuestRecordingState( val selectedEmotion: EmotionChipType? = null, val isEditMode: Boolean = false, val originalAnswer: String = "", - val isCompleteButtonEnabled: Boolean = false, - val hasAnswerChanged: Boolean = false, val fromOffboarding: Boolean = false, -) + val showCompleteModal: Boolean = false, +) { + val hasAnswerChanged: Boolean + get() = questAnswer != originalAnswer + + val isCompleteButtonEnabled: Boolean get() { + val isValid = QuestContentLengthValidator.validButton(questAnswer) + return if (isEditMode) isValid && hasAnswerChanged else isValid + } +} sealed interface QuestRecordingSideEffect { data object NavigateToQuest : QuestRecordingSideEffect diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingViewModel.kt b/app/src/main/java/com/byeboo/app/presentation/quest/record/writing/QuestRecordingViewModel.kt similarity index 88% rename from app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingViewModel.kt rename to app/src/main/java/com/byeboo/app/presentation/quest/record/writing/QuestRecordingViewModel.kt index b78f74551..22e6416ae 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/record/QuestRecordingViewModel.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/record/writing/QuestRecordingViewModel.kt @@ -1,4 +1,4 @@ -package com.byeboo.app.presentation.quest.record +package com.byeboo.app.presentation.quest.record.writing import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -38,10 +38,10 @@ class QuestRecordingViewModel ) : ViewModel() { private val questIdArg: Long = checkNotNull( - savedStateHandle.toRoute().questId, + savedStateHandle.toRoute().questId, ) - private val isEditModeArg: Boolean = savedStateHandle.toRoute().isEditMode - private val fromOffboardingArg: Boolean = savedStateHandle.toRoute().fromOffboarding + private val isEditModeArg: Boolean = savedStateHandle.toRoute().isEditMode + private val fromOffboardingArg: Boolean = savedStateHandle.toRoute().fromOffboarding private val _uiState = MutableStateFlow( @@ -94,7 +94,6 @@ class QuestRecordingViewModel it.copy( questAnswer = detail.questAnswer, originalAnswer = detail.questAnswer, - isCompleteButtonEnabled = false, ) } }.onFailure { @@ -105,6 +104,14 @@ class QuestRecordingViewModel } } + fun onCompleteClicked() { + if (uiState.value.isEditMode) { + onSaveEditClicked() + } else { + openBottomSheet() + } + } + fun onSaveClicked() { val state = uiState.value val questId = state.questId @@ -133,11 +140,11 @@ class QuestRecordingViewModel ), ) _uiState.update { - it.copy(showBottomSheet = false) + it.copy( + showBottomSheet = false, + showCompleteModal = true, + ) } - _sideEffect.emit( - QuestRecordingSideEffect.NavigateToQuestRecordingComplete(questId), - ) }.onFailure { _sideEffect.emit( QuestRecordingSideEffect.ShowSnackBar("서버에 연결할 수 없습니다. 잠시 후 시도해 주세요."), @@ -146,6 +153,18 @@ class QuestRecordingViewModel } } + fun onCompleteModalTimeout() { + val questId = _uiState.value.questId + + _uiState.update { it.copy(showCompleteModal = false) } + + viewModelScope.launch { + _sideEffect.emit( + QuestRecordingSideEffect.NavigateToQuestRecordingComplete(questId), + ) + } + } + private fun onSaveEditClicked() { val state = uiState.value val questId = state.questId @@ -199,21 +218,9 @@ class QuestRecordingViewModel ) { val contentState = QuestContentLengthValidator.validate(isFocused, questAnswer) _uiState.update { prev -> - val hasAnswerChanged = questAnswer != prev.originalAnswer - - val next = - prev.copy( - questAnswer = questAnswer, - contentsState = contentState, - hasAnswerChanged = hasAnswerChanged, - ) - val isButtonEnabled = - completeButtonEnabled( - state = next, - ) - - next.copy( - isCompleteButtonEnabled = isButtonEnabled, + prev.copy( + questAnswer = questAnswer, + contentsState = contentState, ) } } @@ -259,24 +266,6 @@ class QuestRecordingViewModel } } - fun onClickCompleteButton() { - if (uiState.value.isEditMode) { - onSaveEditClicked() - } else { - openBottomSheet() - } - } - - private fun completeButtonEnabled(state: QuestRecordingState): Boolean { - val isValid = QuestContentLengthValidator.validButton(state.questAnswer) - - return if (state.isEditMode) { - isValid && state.hasAnswerChanged - } else { - isValid - } - } - private fun openBottomSheet() { val questNumber = uiState.value.questNumber val answer = uiState.value.questAnswer diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/review/QuestReviewScreen.kt b/app/src/main/java/com/byeboo/app/presentation/quest/review/QuestReviewScreen.kt index ecc6bb4e5..2f4217afa 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/review/QuestReviewScreen.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/review/QuestReviewScreen.kt @@ -15,12 +15,15 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -35,6 +38,7 @@ import coil.compose.SubcomposeAsyncImage import coil.request.CachePolicy import coil.request.ImageRequest import com.byeboo.app.R +import com.byeboo.app.core.designsystem.component.button.ByeBooButton import com.byeboo.app.core.designsystem.component.text.ContentText import com.byeboo.app.core.designsystem.event.LocalSnackBarTrigger import com.byeboo.app.core.designsystem.type.EmotionChipType @@ -66,12 +70,14 @@ fun QuestReviewRoute( effect.questId, true, ) + is QuestReviewSideEffect.NavigateToQuestBehaviorEdit -> navigateToQuestBehaviorEdit( effect.questId, true, effect.imageKey, ) + is QuestReviewSideEffect.ShowSnackBar -> showSnackBar(effect.message) } } @@ -98,6 +104,11 @@ private fun QuestReviewScreen( onEditClick: () -> Unit, modifier: Modifier = Modifier, ) { + val listState = rememberLazyListState() + val canScroll by remember { + derivedStateOf { listState.canScrollForward || listState.canScrollBackward } + } + Column( modifier = modifier @@ -132,77 +143,101 @@ private fun QuestReviewScreen( Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = - PaddingValues( - start = screenWidthDp(24.dp), - end = screenWidthDp(24.dp), - bottom = screenHeightDp(28.dp), - ), - ) { - item { - QuestTitle( - stepNumber = uiState.stepNumber, - questNumber = uiState.questNumber, - createdAt = uiState.createdAt, - questQuestion = uiState.question, - ) - - Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) - } - - if (uiState.imageUrl.isNullOrBlank()) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + start = screenWidthDp(24.dp), + end = screenWidthDp(24.dp), + bottom = screenHeightDp(28.dp), + ), + ) { item { - ContentText( - text = uiState.answer, + QuestTitle( + stepNumber = uiState.stepNumber, + questNumber = uiState.questNumber, + createdAt = uiState.createdAt, + questQuestion = uiState.question, ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) } - } else { - item { - Column( - modifier = - Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(12.dp)), - ) { - SubcomposeAsyncImage( + + if (uiState.imageUrl.isNullOrBlank()) { + item { ContentText(text = uiState.answer) } + } else { + item { + Column( modifier = Modifier - .fillMaxWidth(), - model = - ImageRequest - .Builder(LocalContext.current) - .data(uiState.imageUrl) - .memoryCachePolicy(CachePolicy.DISABLED) - .diskCachePolicy(CachePolicy.DISABLED) - .build(), - contentDescription = "uploaded image", - contentScale = ContentScale.Crop, - loading = { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - }, - ) + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)), + ) { + SubcomposeAsyncImage( + modifier = Modifier.fillMaxWidth(), + model = + ImageRequest + .Builder(LocalContext.current) + .data(uiState.imageUrl) + .memoryCachePolicy(CachePolicy.DISABLED) + .diskCachePolicy(CachePolicy.DISABLED) + .build(), + contentDescription = "uploaded image", + contentScale = ContentScale.Crop, + loading = { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + }, + ) + } + if (uiState.answer.isNotBlank()) { + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + ContentText(uiState.answer) + } } - if (uiState.answer.isNotBlank()) { + } + + item { + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + QuestEmotionDescriptionContent( + questEmotionDescription = uiState.emotionDescription, + emotionType = uiState.selectedEmotion, + ) + } + + if (canScroll) { + item { Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) - ContentText(uiState.answer) + + ByeBooButton( + buttonText = "보리에게 답장 받기", + buttonTextColor = ByeBooTheme.colors.white, + buttonStyle = ByeBooTheme.typography.body2, + buttonBackgroundColor = ByeBooTheme.colors.primary300, + onClick = { /*Todo: ai 버튼 연결 */ }, + ) } } } - item { - Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) - - QuestEmotionDescriptionContent( - questEmotionDescription = uiState.emotionDescription, - emotionType = uiState.selectedEmotion, + if (!canScroll) { + ByeBooButton( + buttonText = "보리에게 답장 받기", + buttonTextColor = ByeBooTheme.colors.white, + buttonStyle = ByeBooTheme.typography.body2, + buttonBackgroundColor = ByeBooTheme.colors.primary300, + onClick = { /*Todo: ai 버튼 연결 */ }, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = screenWidthDp(24.dp)) + .padding(bottom = screenHeightDp(10.dp)), ) } } diff --git a/app/src/main/java/com/byeboo/app/presentation/quest/screen/CommonJourneyScreen.kt b/app/src/main/java/com/byeboo/app/presentation/quest/screen/CommonJourneyScreen.kt index 1d85e06aa..86ef5317b 100644 --- a/app/src/main/java/com/byeboo/app/presentation/quest/screen/CommonJourneyScreen.kt +++ b/app/src/main/java/com/byeboo/app/presentation/quest/screen/CommonJourneyScreen.kt @@ -40,6 +40,7 @@ import java.time.LocalDate fun CommonJourneyScreen( state: CommonJourneyState, onDateChange: (LocalDate) -> Unit, + onCommonQuestClick: (Long) -> Unit, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() @@ -141,7 +142,7 @@ fun CommonJourneyScreen( ) Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) ByeBooButton( - onClick = { /* TODO : 답변 작성 화면 이동 */ }, + onClick = { onCommonQuestClick(0) }, // TODO : 답변 작성 화면 이동 buttonText = "답변 작성하기", buttonStyle = ByeBooTheme.typography.body2, buttonTextColor = ByeBooTheme.colors.primary500,