diff --git a/core/alarm/src/main/java/com/yapp/alarm/services/AlarmService.kt b/core/alarm/src/main/java/com/yapp/alarm/services/AlarmService.kt index 660d7c53..70cc193d 100644 --- a/core/alarm/src/main/java/com/yapp/alarm/services/AlarmService.kt +++ b/core/alarm/src/main/java/com/yapp/alarm/services/AlarmService.kt @@ -20,7 +20,6 @@ import androidx.core.net.toUri import com.yapp.alarm.AlarmConstants import com.yapp.alarm.AndroidAlarmScheduler import com.yapp.alarm.pendingIntent.interaction.createAlarmAlertPendingIntent -import com.yapp.alarm.pendingIntent.interaction.createAlarmDismissPendingIntent import com.yapp.alarm.pendingIntent.interaction.createAlarmSnoozePendingIntent import com.yapp.alarm.pendingIntent.interaction.createNavigateToMissionPendingIntent import com.yapp.domain.model.Alarm @@ -135,7 +134,7 @@ class AlarmService : Service() { val alarmAlertPendingIntent = createAlarmAlertPendingIntent(applicationContext, alarm) - val alarmDismissPendingIntent = if (shouldNavigateToMission) { + /*val alarmDismissPendingIntent = if (shouldNavigateToMission) { createNavigateToMissionPendingIntent( applicationContext = applicationContext, notificationId = alarm.id, @@ -147,7 +146,14 @@ class AlarmService : Service() { applicationContext = applicationContext, pendingIntentId = alarm.id, ) - } + }*/ + + val alarmDismissPendingIntent = createNavigateToMissionPendingIntent( + applicationContext = applicationContext, + notificationId = alarm.id, + missionType = alarm.missionType.value, + missionCount = alarm.missionCount, + ) val snoozePendingIntent = if (alarm.isSnoozeEnabled && alarm.snoozeCount != 0) { createAlarmSnoozePendingIntent(applicationContext, alarm) diff --git a/core/common/src/main/java/com/yapp/common/navigation/OrbitNavigator.kt b/core/common/src/main/java/com/yapp/common/navigation/OrbitNavigator.kt index 34ceed6f..ec884f97 100644 --- a/core/common/src/main/java/com/yapp/common/navigation/OrbitNavigator.kt +++ b/core/common/src/main/java/com/yapp/common/navigation/OrbitNavigator.kt @@ -11,6 +11,7 @@ import com.yapp.common.navigation.route.FortuneBaseRoute import com.yapp.common.navigation.route.FortuneDestination import com.yapp.common.navigation.route.HomeBaseRoute import com.yapp.common.navigation.route.HomeDestination +import com.yapp.common.navigation.route.MissionRoute import com.yapp.common.navigation.route.OnboardingBaseRoute import com.yapp.common.navigation.route.OnboardingDestination import com.yapp.common.navigation.route.SettingBaseRoute @@ -57,6 +58,21 @@ class OrbitNavigator( navController.navigate(AlarmInteractionDestination.AlarmSnoozeTimer(alarm), navOptions) } + fun navigateToMissionPreview( + missionType: Int, + missionCount: Int, + navOptions: NavOptions? = null, + ) { + navController.navigate( + MissionRoute( + missionType = "$missionType", + missionCount = "$missionCount", + missionMode = "PREVIEW", + ), + navOptions, + ) + } + fun navigateToFortune(navOptions: NavOptions? = null) { navController.navigate(FortuneBaseRoute, navOptions) } diff --git a/core/common/src/main/java/com/yapp/common/navigation/route/MissionRoute.kt b/core/common/src/main/java/com/yapp/common/navigation/route/MissionRoute.kt index d5a04949..2ffd29d6 100644 --- a/core/common/src/main/java/com/yapp/common/navigation/route/MissionRoute.kt +++ b/core/common/src/main/java/com/yapp/common/navigation/route/MissionRoute.kt @@ -1,6 +1,15 @@ package com.yapp.common.navigation.route +import com.yapp.domain.MissionMode import kotlinx.serialization.Serializable @Serializable -data object MissionRoute +data class MissionRoute( + val missionType: String, + val missionCount: String, + val missionMode: String = MissionMode.REAL.name, +) { + companion object { + const val route = "mission" + } +} diff --git a/domain/src/main/java/com/yapp/domain/MissionMode.kt b/domain/src/main/java/com/yapp/domain/MissionMode.kt new file mode 100644 index 00000000..b047c7c8 --- /dev/null +++ b/domain/src/main/java/com/yapp/domain/MissionMode.kt @@ -0,0 +1,13 @@ +package com.yapp.domain + +enum class MissionMode { + REAL, + PREVIEW, + ; + + companion object { + fun fromRaw(raw: String?): MissionMode { + return raw?.let { entries.find { it.name == raw } } ?: REAL + } + } +} diff --git a/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditContract.kt b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditContract.kt index 3396114a..eb99cf01 100644 --- a/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditContract.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditContract.kt @@ -85,6 +85,7 @@ sealed class AlarmAddEditContract { data object ToggleHolidaySkipOption : Action() data object ToggleSnoozeOption : Action() data class SaveMission(val type: MissionType, val count: Int) : Action() + data class NavigateToMissionPreview(val missionType: MissionType, val missionCount: Int) : Action() data class SetSnoozeInterval(val index: Int) : Action() data class SetSnoozeRepeatCount(val index: Int) : Action() data object ToggleVibrationOption : Action() @@ -103,6 +104,11 @@ sealed class AlarmAddEditContract { sealed class SideEffect : com.yapp.ui.base.SideEffect { data object NavigateBack : SideEffect() + data class NavigateToMissionPreview( + val missionType: MissionType, + val missionCount: Int, + ) : SideEffect() + data class SaveAlarm(val id: Long) : SideEffect() data class UpdateAlarm(val id: Long) : SideEffect() diff --git a/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditScreen.kt b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditScreen.kt index 1eb21d47..8bc32ab5 100644 --- a/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditScreen.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditScreen.kt @@ -104,6 +104,12 @@ private suspend fun handleSideEffect( is AlarmAddEditContract.SideEffect.NavigateBack -> { navigator.navigateBack() } + is AlarmAddEditContract.SideEffect.NavigateToMissionPreview -> { + navigator.navigateToMissionPreview( + missionType = sideEffect.missionType.value, + missionCount = sideEffect.missionCount, + ) + } is AlarmAddEditContract.SideEffect.SaveAlarm -> { navigator.navController.previousBackStackEntry ?.savedStateHandle @@ -241,7 +247,13 @@ fun AlarmAddEditContent( ), ) }, - onPreviewMission = { + onPreviewMission = { missionType, missionCount -> + eventDispatcher( + AlarmAddEditContract.Action.NavigateToMissionPreview( + missionType = missionType, + missionCount = missionCount, + ), + ) }, ) diff --git a/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditViewModel.kt b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditViewModel.kt index 5e51ba96..fc68790e 100644 --- a/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditViewModel.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditViewModel.kt @@ -181,6 +181,7 @@ class AlarmAddEditViewModel @Inject constructor( is AlarmAddEditContract.Action.ToggleSpecificDaySelection -> toggleSpecificDaySelection(action.day) is AlarmAddEditContract.Action.ToggleHolidaySkipOption -> toggleHolidaySkipOption() is AlarmAddEditContract.Action.SaveMission -> saveMission(action.type, action.count) + is AlarmAddEditContract.Action.NavigateToMissionPreview -> navigateToMissionPreview(action.missionType, action.missionCount) is AlarmAddEditContract.Action.ToggleSnoozeOption -> toggleSnoozeOption() is AlarmAddEditContract.Action.SetSnoozeInterval -> setSnoozeInterval(action.index) is AlarmAddEditContract.Action.SetSnoozeRepeatCount -> setSnoozeRepeatCount(action.index) @@ -211,6 +212,21 @@ class AlarmAddEditViewModel @Inject constructor( postSideEffect(AlarmAddEditContract.SideEffect.NavigateBack) } + private fun navigateToMissionPreview( + missionType: MissionType, + missionCount: Int, + ) = intent { + val newTimeState = state.timeState.copy( + initialTime = state.timeState.currentTime, + ) + reduce { + state.copy( + timeState = newTimeState, + ) + } + postSideEffect(AlarmAddEditContract.SideEffect.NavigateToMissionPreview(missionType, missionCount)) + } + private fun saveAlarm() = intent { val newAlarm = state.toAlarm() diff --git a/feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmMissionBottomSheet.kt b/feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmMissionBottomSheet.kt index 5ea69a22..9cb8669e 100644 --- a/feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmMissionBottomSheet.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmMissionBottomSheet.kt @@ -68,7 +68,7 @@ internal fun AlarmMissionBottomSheet( isSheetOpen: Boolean, onDismiss: () -> Unit, onSaveMission: (MissionType, Int) -> Unit, - onPreviewMission: (MissionType) -> Unit, + onPreviewMission: (MissionType, Int) -> Unit, ) { var currentStep by remember { mutableStateOf(AlarmMissionSelectBottomSheetType.MISSION_SETTING) } @@ -147,7 +147,7 @@ internal fun AlarmMissionBottomSheet( onDismiss() }, onPreview = { - onPreviewMission(selectedMissionType) + onPreviewMission(selectedMissionType, selectedMissionCount) }, ) } @@ -475,7 +475,7 @@ private fun MissionDetailContent( onBack: () -> Unit, onClose: () -> Unit, onSave: () -> Unit, - onPreview: (MissionType) -> Unit, + onPreview: () -> Unit, ) { val (title, lottieRes) = when (missionType) { MissionType.SHAKE -> @@ -569,9 +569,7 @@ private fun MissionDetailContent( ) { OrbitButton( label = stringResource(id = feature.home.R.string.mission_detail_content_btn_preview), - onClick = { - onPreview(missionType) - }, + onClick = onPreview, useFillMaxWidth = false, enabled = true, containerColor = OrbitTheme.colors.gray_600, @@ -657,7 +655,7 @@ private fun AlarmMissionSelectBottomSheetPreview() { isSheetOpen = true, onDismiss = {}, onSaveMission = { _, _ -> }, - onPreviewMission = {}, + onPreviewMission = { _, _ -> }, ) } } diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionContract.kt b/feature/mission/src/main/java/com/yapp/mission/MissionContract.kt index b21e6909..9aa81001 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionContract.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionContract.kt @@ -1,10 +1,12 @@ package com.yapp.mission +import com.yapp.domain.MissionMode import com.yapp.domain.model.MissionType sealed class MissionContract { data class State( + val missionMode: MissionMode = MissionMode.REAL, val missionType: MissionType = MissionType.TAP, val isMissionTypeLoading: Boolean = true, val missionCount: Int = 10, @@ -20,6 +22,7 @@ sealed class MissionContract { ) : com.yapp.ui.base.UiState sealed class Action { + data object NavigateBack : Action() data object ShakeCard : Action() data object ClickCard : Action() data object ShowExitDialog : Action() diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt b/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt index 1e11f8f6..247a9e5c 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt @@ -25,7 +25,10 @@ fun NavGraphBuilder.missionScreen( handleSideEffect(it, navigator) } - MissionRoute(viewModel) + MissionRoute( + navigator = navigator, + viewModel = viewModel, + ) } } @@ -37,7 +40,7 @@ private fun handleSideEffect( MissionContract.SideEffect.NavigateToFortune -> { navigator.navigateToFortune( navOptions = navOptions { - popUpTo(MissionRoute) { + popUpTo(MissionRoute.route) { inclusive = true } }, @@ -47,7 +50,7 @@ private fun handleSideEffect( MissionContract.SideEffect.NavigateToHome -> { navigator.navigateToHome( navOptions = navOptions { - popUpTo(MissionRoute) { + popUpTo(MissionRoute.route) { inclusive = true } }, diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionScreen.kt b/feature/mission/src/main/java/com/yapp/mission/MissionScreen.kt index 4f8d85ef..ad178833 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionScreen.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionScreen.kt @@ -10,14 +10,21 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures 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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues 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.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -32,6 +39,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -39,7 +47,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.yapp.analytics.AnalyticsEvent import com.yapp.analytics.AnalyticsHelper import com.yapp.analytics.LocalAnalyticsHelper +import com.yapp.common.navigation.OrbitNavigator import com.yapp.designsystem.theme.OrbitTheme +import com.yapp.domain.MissionMode import com.yapp.domain.model.MissionType import com.yapp.mission.component.FlipCard import com.yapp.mission.component.MissionProgressBar @@ -48,9 +58,13 @@ import com.yapp.ui.component.lottie.LottieAnimation import com.yapp.ui.extensions.customClickable import com.yapp.ui.utils.heightForScreenPercentage import com.yapp.ui.utils.paddingForScreenPercentage +import feature.mission.R @Composable -fun MissionRoute(viewModel: MissionViewModel = hiltViewModel()) { +fun MissionRoute( + viewModel: MissionViewModel = hiltViewModel(), + navigator: OrbitNavigator, +) { val state by viewModel.container.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current @@ -60,6 +74,21 @@ fun MissionRoute(viewModel: MissionViewModel = hiltViewModel()) { } } + BackHandler { + if (state.missionMode == MissionMode.PREVIEW) { + navigator.navigateBack() + return@BackHandler + } + + viewModel.processAction( + if (state.showExitDialog) { + MissionContract.Action.HideExitDialog + } else { + MissionContract.Action.ShowExitDialog + }, + ) + } + LaunchedEffect(Unit) { shakeDetector.start() } @@ -77,10 +106,6 @@ fun MissionRoute(viewModel: MissionViewModel = hiltViewModel()) { ) } -/** - * Mission 상태에 따라 적절한 화면을 구성하는 메인 컨테이너. - * 로딩, 콘텐츠, 성공 오버레이, 다이얼로그 등 분기 처리 포함. - */ @Composable fun MissionScreen( stateProvider: () -> MissionContract.State, @@ -90,17 +115,9 @@ fun MissionScreen( val state = stateProvider() val analytics = LocalAnalyticsHelper.current - BackHandler { - eventDispatcher( - if (state.showExitDialog) { - MissionContract.Action.HideExitDialog - } else { - MissionContract.Action.ShowExitDialog - }, - ) - } - - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier.fillMaxSize(), + ) { if (state.isMissionTypeLoading || state.missionType == MissionType.NONE) { MissionLoadingScreen() return @@ -113,7 +130,10 @@ fun MissionScreen( modifier = Modifier.matchParentSize(), ) - MissionContent(state, eventDispatcher) + MissionContent( + state = state, + eventDispatcher = eventDispatcher, + ) if (state.showExitDialog) { ExitDialog(state, eventDispatcher, onFinish, analytics) @@ -128,12 +148,36 @@ fun MissionScreen( eventDispatcher(MissionContract.Action.RetryPostFortune) } } + + if (state.missionMode == MissionMode.PREVIEW) { + val insets = WindowInsets.navigationBars.asPaddingValues() + + Button( + onClick = { + eventDispatcher(MissionContract.Action.NavigateBack) + }, + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = OrbitTheme.colors.white, + contentColor = OrbitTheme.colors.gray_900, + ), + contentPadding = PaddingValues( + horizontal = 24.dp, + vertical = 12.dp, + ), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = insets.calculateBottomPadding() + 28.dp), + ) { + Text( + text = stringResource(id = R.string.mission_preview_exit), + style = OrbitTheme.typography.body1SemiBold, + ) + } + } } } -/** - * 미션 콘텐츠 본문. TopBar, 진행 바, 상태별 게임 포함. - */ @Composable fun MissionContent( state: MissionContract.State, @@ -143,7 +187,10 @@ fun MissionContent( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { - MissionTopAppBar(onExit = { eventDispatcher(MissionContract.Action.ShowExitDialog) }) + MissionTopAppBar( + mode = state.missionMode, + onExit = { eventDispatcher(MissionContract.Action.ShowExitDialog) }, + ) MissionProgressBarSection(state) MissionLabel(state) Spacer(modifier = Modifier.heightForScreenPercentage(0.0665f)) @@ -153,7 +200,7 @@ fun MissionContent( if (state.currentCount == 0) { MissionShakeInitialImage() } else { - FlipCard(state = state, eventDispatcher = eventDispatcher) + FlipCard(state) } } @@ -168,11 +215,11 @@ fun MissionContent( } } -/** - * '나가기' 버튼이 포함된 미션 상단 앱바 영역. - */ @Composable -fun MissionTopAppBar(onExit: () -> Unit) { +fun MissionTopAppBar( + mode: MissionMode, + onExit: () -> Unit, +) { Spacer(modifier = Modifier.heightForScreenPercentage(0.066f)) Box( modifier = Modifier @@ -181,34 +228,35 @@ fun MissionTopAppBar(onExit: () -> Unit) { contentAlignment = Alignment.TopEnd, ) { Row( - modifier = Modifier.customClickable( - rippleEnabled = false, - fadeOnPress = true, - pressedAlpha = 0.5f, - onClick = onExit, - ), + modifier = Modifier + .height(26.dp) + .customClickable( + rippleEnabled = false, + fadeOnPress = true, + pressedAlpha = 0.5f, + onClick = onExit, + ), ) { - Icon( - painter = painterResource(id = core.designsystem.R.drawable.ic_cancel), - contentDescription = null, - tint = OrbitTheme.colors.white, - modifier = Modifier.size(24.dp), - ) - Text( - text = "나가기", - color = OrbitTheme.colors.white, - style = OrbitTheme.typography.body1SemiBold, - modifier = Modifier - .padding(start = 4.dp) - .align(Alignment.CenterVertically), - ) + if (mode == MissionMode.REAL) { + Icon( + painter = painterResource(id = core.designsystem.R.drawable.ic_cancel), + contentDescription = null, + tint = OrbitTheme.colors.white, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(id = R.string.exit), + color = OrbitTheme.colors.white, + style = OrbitTheme.typography.body1SemiBold, + modifier = Modifier + .padding(start = 4.dp) + .align(Alignment.CenterVertically), + ) + } } } } -/** - * 미션 진행도 ProgressBar 섹션. - */ @Composable fun MissionProgressBarSection(state: MissionContract.State) { Spacer(modifier = Modifier.heightForScreenPercentage(0.0246f)) @@ -223,14 +271,15 @@ fun MissionProgressBarSection(state: MissionContract.State) { Spacer(modifier = Modifier.heightForScreenPercentage(0.06f)) } -/** - * 미션 안내 문구 및 현재 카운트. - */ @Composable fun MissionLabel(state: MissionContract.State) { - val instruction = - if (state.missionType == MissionType.SHAKE) "${state.missionCount}회를 흔들어 부적을 뒤집어줘" else "${state.missionCount}회를 눌러 편지를 열어줘" - val count = state.currentCount + val instruction = stringResource( + id = when (state.missionType) { + MissionType.SHAKE -> R.string.mission_instruction_shake + else -> R.string.mission_instruction_tap + }, + state.missionCount, + ) Text( text = instruction, @@ -239,15 +288,12 @@ fun MissionLabel(state: MissionContract.State) { ) Spacer(modifier = Modifier.heightForScreenPercentage(0.005f)) Text( - text = count.toString(), + text = state.currentCount.toString(), color = OrbitTheme.colors.white, style = OrbitTheme.typography.displaySemiBold, ) } -/** - * 흔들기 미션 초기 이미지. - */ @Composable fun MissionShakeInitialImage() { Image( @@ -259,9 +305,6 @@ fun MissionShakeInitialImage() { ) } -/** - * 클릭 미션 카드. 클릭 시 애니메이션 및 상태 변화. - */ @Composable fun MissionClickCard( state: MissionContract.State, @@ -297,9 +340,6 @@ fun MissionClickCard( } } -/** - * 미션 종료 시 나가기 다이얼로그. - */ @Composable fun ExitDialog( state: MissionContract.State, @@ -308,10 +348,10 @@ fun ExitDialog( analytics: AnalyticsHelper, ) { OrbitDialog( - title = "나가면 운세를 받을 수 없어요", - message = "미션을 수행하지 않고 나가시겠어요?", - confirmText = "나가기", - cancelText = "취소", + title = stringResource(id = R.string.mission_exit_dialog_title), + message = stringResource(id = R.string.mission_exit_dialog_message), + confirmText = stringResource(id = R.string.exit), + cancelText = stringResource(id = R.string.cancel), onConfirm = { analytics.logEvent( AnalyticsEvent( @@ -331,9 +371,6 @@ fun ExitDialog( ) } -/** - * 미션 성공 시 오버레이 화면. - */ @Composable fun MissionSuccessOverlay() { Box( @@ -357,7 +394,7 @@ fun MissionSuccessOverlay() { play = true, ) Text( - text = "미션 성공!", + text = stringResource(id = R.string.mission_success), color = OrbitTheme.colors.white, style = OrbitTheme.typography.title1Bold, modifier = Modifier @@ -368,22 +405,16 @@ fun MissionSuccessOverlay() { } } -/** - * 오류 발생 시 다이얼로그. - */ @Composable fun ErrorDialog(message: String, onConfirm: () -> Unit) { OrbitDialog( - title = "오류", + title = stringResource(id = R.string.error), message = message, - confirmText = "확인", + confirmText = stringResource(id = R.string.confirm), onConfirm = onConfirm, ) } -/** - * 로딩 화면. 미션 타입 로딩 중에 표시. - */ @Composable fun MissionLoadingScreen() { Box( @@ -397,12 +428,33 @@ fun MissionLoadingScreen() { } } +@Composable +@Preview +private fun MissionRouteReal() { + MissionScreen( + stateProvider = { + MissionContract.State( + isMissionTypeLoading = false, + missionType = MissionType.TAP, + currentCount = 0, + showFinalAnimation = false, + playWhenClick = false, + showExitDialog = false, + isMissionCompleted = false, + ) + }, + eventDispatcher = {}, + onFinish = {}, + ) +} + @Composable @Preview private fun MissionRoutePreview() { MissionScreen( stateProvider = { MissionContract.State( + missionMode = MissionMode.PREVIEW, isMissionTypeLoading = false, missionType = MissionType.TAP, currentCount = 0, diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt b/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt index dc4b8d84..33650e31 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import com.yapp.alarm.pendingIntent.interaction.createAlarmDismissIntent import com.yapp.analytics.AnalyticsEvent import com.yapp.analytics.AnalyticsHelper +import com.yapp.domain.MissionMode import com.yapp.domain.model.MissionType import com.yapp.domain.repository.FortuneRepository import com.yapp.domain.repository.UserInfoRepository @@ -31,7 +32,7 @@ class MissionViewModel @Inject constructor( private val fortuneRepository: FortuneRepository, private val userInfoRepository: UserInfoRepository, private val app: Application, - private val savedStateHandle: SavedStateHandle, + savedStateHandle: SavedStateHandle, ) : ViewModel(), ContainerHost { override val container: Container = container( @@ -40,28 +41,36 @@ class MissionViewModel @Inject constructor( savedStateHandle.get("notificationId")?.toLong()?.let { sendAlarmDismissIntent(it) } - loadMissionInfo() + loadMissionInfo( + missionTypeRaw = savedStateHandle.get("missionType"), + missionCountRaw = savedStateHandle.get("missionCount"), + missionModeRaw = savedStateHandle.get("missionMode"), + ) } fun processAction(action: MissionContract.Action) { when (action) { - is MissionContract.Action.ShakeCard -> handleShake() - is MissionContract.Action.ClickCard -> handleClick() + is MissionContract.Action.NavigateBack -> navigateBack() + is MissionContract.Action.ShakeCard -> handleMissionProgress(MissionType.SHAKE) + is MissionContract.Action.ClickCard -> handleMissionProgress(MissionType.TAP) is MissionContract.Action.ShowExitDialog -> showExitDialog() is MissionContract.Action.HideExitDialog -> hideExitDialog() is MissionContract.Action.RetryPostFortune -> retryPostFortune() } } - private fun loadMissionInfo() = intent { - val missionTypeString = savedStateHandle.get("missionType") - val missionCountString = savedStateHandle.get("missionCount") - - val missionType = missionTypeString?.toIntOrNull() ?: MissionType.TAP.value - val missionCount = missionCountString?.toIntOrNull() ?: 10 + private fun loadMissionInfo( + missionTypeRaw: String?, + missionCountRaw: String?, + missionModeRaw: String?, + ) = intent { + val missionType = missionTypeRaw?.toIntOrNull() ?: MissionType.TAP.value + val missionCount = missionCountRaw?.toIntOrNull() ?: 10 + val missionMode = MissionMode.fromRaw(missionModeRaw) reduce { state.copy( + missionMode = missionMode, missionType = MissionType.fromInt(missionType), missionCount = missionCount, isMissionTypeLoading = false, @@ -69,6 +78,10 @@ class MissionViewModel @Inject constructor( } } + private fun navigateBack() = intent { + postSideEffect(MissionContract.SideEffect.NavigateBack) + } + private fun showExitDialog() = intent { reduce { state.copy(showExitDialog = true) } } @@ -77,45 +90,35 @@ class MissionViewModel @Inject constructor( reduce { state.copy(showExitDialog = false) } } - private fun handleShake() = intent { - if (state.missionType != MissionType.SHAKE) return@intent + private fun handleMissionProgress(missionType: MissionType) = intent { + val isLast = state.currentCount >= state.missionCount - 1 + val nextCount = state.currentCount + 1 + + performHapticSuccess() - val currentCount = state.currentCount - if (currentCount < state.missionCount - 1) { - performHapticSuccess() - reduce { state.copy(currentCount = currentCount + 1) } - } else if (!state.isFlipped) { - completeMission(type = "shake") + if (isLast) { + completeMission(type = missionType.name.lowercase()) reduce { state.copy( isMissionCompleted = true, currentCount = state.missionCount, - isFlipped = true, + showFinalAnimation = true, ) } delay(500) - } - } + } else { + val transientState = if (missionType == MissionType.TAP) { + state.copy(currentCount = nextCount, playWhenClick = true) + } else { + state.copy(currentCount = nextCount) + } - private fun handleClick() = intent { - if (state.missionType != MissionType.TAP) return@intent + reduce { transientState } - val currentCount = state.currentCount - if (currentCount < state.missionCount - 1) { - performHapticSuccess() - reduce { state.copy(currentCount = currentCount + 1, playWhenClick = true) } - delay(500) - reduce { state.copy(playWhenClick = false) } - } else { - completeMission("click") - reduce { - state.copy( - isMissionCompleted = true, - currentCount = state.missionCount, - showFinalAnimation = true, - ) + if (missionType == MissionType.TAP) { + delay(500) + reduce { state.copy(playWhenClick = false) } } - delay(500) } } @@ -144,10 +147,14 @@ class MissionViewModel @Inject constructor( postFortune(isRetry = true) } - private fun completeMission(type: String) { + private fun completeMission(type: String) = intent { performHapticSuccess() logMissionSuccess(type) - postFortune() + if (state.missionMode == MissionMode.REAL) { + postFortune() + } else { + postSideEffect(MissionContract.SideEffect.NavigateBack) + } } private fun performHapticSuccess() { diff --git a/feature/mission/src/main/java/com/yapp/mission/component/FlipCard.kt b/feature/mission/src/main/java/com/yapp/mission/component/FlipCard.kt index 980cf295..14e41a39 100644 --- a/feature/mission/src/main/java/com/yapp/mission/component/FlipCard.kt +++ b/feature/mission/src/main/java/com/yapp/mission/component/FlipCard.kt @@ -25,7 +25,6 @@ import com.yapp.mission.MissionContract @Composable fun FlipCard( state: MissionContract.State, - eventDispatcher: (MissionContract.Action) -> Unit, ) { val rotationZ = remember { Animatable(0f) } val rotationY = remember { Animatable(state.rotationY) } @@ -109,7 +108,6 @@ fun FlipCardPreview() { ) { FlipCard( state = state.copy(rotationY = rotationY, rotationZ = rotationZ), - eventDispatcher = {}, ) } } diff --git a/feature/mission/src/main/res/values/strings.xml b/feature/mission/src/main/res/values/strings.xml new file mode 100644 index 00000000..3fd93606 --- /dev/null +++ b/feature/mission/src/main/res/values/strings.xml @@ -0,0 +1,14 @@ + + + 나가기 + 취소 + 확인 + 오류 + + 미리보기 종료 + 나가면 운세를 받을 수 없어요 + 미션을 수행하지 않고 나가시겠어요? + 미션 성공! + %1$d회를 흔들어 부적을 뒤집어줘 + %1$d회를 눌러 편지를 열어줘 +