Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT] 터치 미션을 구현합니다. #200

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions core/designsystem/src/main/res/raw/mission_letter_tap.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
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.Modifier
import androidx.compose.ui.graphics.graphicsLayer
Expand All @@ -26,12 +27,17 @@ fun LottieAnimation(
contentScale: ContentScale = ContentScale.FillWidth,
scaleXAdjustment: Float = 1f,
scaleYAdjustment: Float = 1f,
play: Boolean = iterations == 1,
restartOnPlay: Boolean = false,
onAnimationEnd: (() -> Unit)? = null,
) {
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(resId))
val progress by animateLottieCompositionAsState(
val isPlaying = remember { mutableStateOf(iterations == LottieConstants.IterateForever || play) }
val animationState = animateLottieCompositionAsState(
composition = composition,
iterations = iterations,
isPlaying = isPlaying.value,
restartOnPlay = restartOnPlay,
)
val alpha = remember { Animatable(0f) }

Expand All @@ -44,9 +50,18 @@ fun LottieAnimation(
}
}

LaunchedEffect(progress) {
if (progress == 1f) {
LaunchedEffect(play) {
if (play) {
isPlaying.value = true
}
}

LaunchedEffect(animationState.progress) {
if (animationState.progress == 1f) {
onAnimationEnd?.invoke()
if (iterations == 1) {
isPlaying.value = false
}
}
}

Expand All @@ -62,7 +77,7 @@ fun LottieAnimation(
if (composition != null) {
com.airbnb.lottie.compose.LottieAnimation(
composition = composition,
progress = { progress },
progress = { animationState.progress },
modifier = Modifier.fillMaxSize(),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ package com.yapp.mission
sealed class MissionContract {

data class State(
val missionType: MissionType = MissionType.Click,
val showOverlayText: Boolean = false,
val showOverlay: Boolean = true,
val missionProgress: Int = 0,
val isMissionCompleted: Boolean = false,
val isFlipped: Boolean = false,
val shakeCount: Int = 0,
val clickCount: Int = 0,
val playWhenClick: Boolean = false,
val showFinalAnimation: Boolean = false,
val isFlipped: Boolean = false,
val rotationY: Float = 0f,
val rotationZ: Float = 0f,
val showExitDialog: Boolean = false,
Expand All @@ -26,6 +30,11 @@ sealed class MissionContract {
object RetryPostFortune : Action()
}

sealed class MissionType {
data object Shake : MissionType()
data object Click : MissionType()
}

sealed class SideEffect : com.yapp.ui.base.SideEffect {
data class Navigate(
val route: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ package com.yapp.mission

import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.Image
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.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
Expand Down Expand Up @@ -137,7 +140,10 @@ fun MissionProgressScreen(

Spacer(modifier = Modifier.heightForScreenPercentage(0.0246f))
MissionProgressBar(
currentProgress = state.shakeCount,
currentProgress = when (state.missionType) {
is MissionContract.MissionType.Shake -> state.shakeCount
is MissionContract.MissionType.Click -> state.clickCount
},
totalProgress = 10,
modifier = Modifier
.fillMaxWidth()
Expand All @@ -147,23 +153,56 @@ fun MissionProgressScreen(
)
Spacer(modifier = Modifier.heightForScreenPercentage(0.06f))
Text(
text = "10회를 흔들어야 운세를 받아요",
text = if (state.missionType is MissionContract.MissionType.Shake) "10회를 흔들어야 운세를 받아요" else "10회를 눌러야 운세를 받아요",
color = OrbitTheme.colors.white,
style = OrbitTheme.typography.heading2SemiBold,
modifier = Modifier.alpha(if (state.showOverlay) 0f else 1f),
)
Spacer(modifier = Modifier.heightForScreenPercentage(0.005f))
Text(
text = state.shakeCount.toString(),
text = when (state.missionType) {
is MissionContract.MissionType.Shake -> state.shakeCount.toString()
is MissionContract.MissionType.Click -> state.clickCount.toString()
},
color = OrbitTheme.colors.white,
style = OrbitTheme.typography.displaySemiBold,
modifier = Modifier.alpha(if (state.showOverlay) 0f else 1f),
)
Spacer(modifier = Modifier.heightForScreenPercentage(0.0665f))
FlipCard(
state = state,
eventDispatcher = eventDispatcher,
)

Spacer(modifier = Modifier.heightForScreenPercentage(if (state.missionType is MissionContract.MissionType.Shake) 0.0665f else 0.1f))
if (state.missionType is MissionContract.MissionType.Shake) {
FlipCard(
state = state,
eventDispatcher = eventDispatcher,
)
} else if (state.missionType is MissionContract.MissionType.Click) {
Crossfade(
targetState = state.showFinalAnimation,
animationSpec = tween(durationMillis = 500),
) { showFinal ->
LottieAnimation(
modifier = Modifier
.aspectRatio(12f / 9f)
.pointerInput(Unit) {
detectTapGestures(
onTap = {
if (!state.showFinalAnimation) {
eventDispatcher(MissionContract.Action.ClickCard)
}
},
)
},
resId = if (showFinal) {
core.designsystem.R.raw.mission_letter_open
} else {
core.designsystem.R.raw.mission_letter_tap
},
play = state.playWhenClick || showFinal,
restartOnPlay = true,
iterations = 1,
)
}
}
}
}

Expand Down Expand Up @@ -191,7 +230,7 @@ fun MissionProgressScreen(
) + fadeIn(animationSpec = tween(durationMillis = 300)),
) {
Text(
text = "흔들기 시작!",
text = if (state.missionType is MissionContract.MissionType.Shake) "흔들기 시작!" else "누르기 시작!",
color = OrbitTheme.colors.white,
style = OrbitTheme.typography.title1Bold,
)
Expand All @@ -210,7 +249,10 @@ fun MissionProgressScreen(
AnalyticsEvent(
type = "mission_fail",
properties = mapOf(
AnalyticsEvent.MissionPropertiesKeys.MISSION_TYPE to "shake",
AnalyticsEvent.MissionPropertiesKeys.MISSION_TYPE to when (state.missionType) {
is MissionContract.MissionType.Shake -> "shake"
is MissionContract.MissionType.Click -> "click"
},
),
),
)
Expand Down Expand Up @@ -246,6 +288,7 @@ fun MissionProgressScreen(
scaleYAdjustment = 1.3f,
resId = core.designsystem.R.raw.mission_success,
iterations = 1,
play = true,
)
Text(
text = "미션 성공!",
Expand Down
14 changes: 10 additions & 4 deletions feature/mission/src/main/java/com/yapp/mission/MissionScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -83,21 +83,27 @@ fun MissionScreen(
Spacer(modifier = Modifier.heightForScreenPercentage(0.110f))
MissionTag(label = "기상미션")
Spacer(modifier = Modifier.heightForScreenPercentage(0.0418f))
MissionLabel(label = "10회를 흔들어", style = OrbitTheme.typography.headline2Medium)
MissionLabel(label = if (state.missionType is MissionContract.MissionType.Shake) "10회를 흔들어" else "10회를 눌러서", style = OrbitTheme.typography.headline2Medium)
Spacer(modifier = Modifier.heightForScreenPercentage(0.01f))
MissionLabel(label = "부적을 뒤집어줘", style = OrbitTheme.typography.title2Bold)
MissionLabel(label = if (state.missionType is MissionContract.MissionType.Shake) "부적을 뒤집어줘" else "편지를 열어줘", style = OrbitTheme.typography.title2Bold)
}
Column(
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(id = core.designsystem.R.drawable.img_mission_main),
painter = painterResource(
if (state.missionType is MissionContract.MissionType.Shake) {
core.designsystem.R.drawable.img_mission_main
} else {
core.designsystem.R.drawable.ic_mission_main_letter
},
),
contentDescription = "",
modifier = Modifier
.fillMaxWidth()
.scale(1.1f),
.scale(if (state.missionType is MissionContract.MissionType.Shake) 1.1f else 1.0f),
)
}
Column(
Expand Down
42 changes: 39 additions & 3 deletions feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.random.Random

@HiltViewModel
class MissionViewModel @Inject constructor(
Expand All @@ -31,7 +32,9 @@ class MissionViewModel @Inject constructor(
private val app: Application,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<MissionContract.State, MissionContract.SideEffect>(
MissionContract.State(),
MissionContract.State(
missionType = if (Random.nextBoolean()) MissionContract.MissionType.Shake else MissionContract.MissionType.Click,
),
) {
init {
val notificationId = savedStateHandle.get<String>("notificationId")?.toLong()
Expand All @@ -54,17 +57,19 @@ class MissionViewModel @Inject constructor(

is MissionContract.Action.StartOverlayTimer -> startOverlayTimer()

is MissionContract.Action.ShakeCard, is MissionContract.Action.ClickCard -> handleIncreaseCount()
is MissionContract.Action.ShakeCard -> handleShake()
is MissionContract.Action.ClickCard -> handleClick()

is MissionContract.Action.ShowExitDialog -> updateState { copy(showExitDialog = true) }
is MissionContract.Action.HideExitDialog -> updateState { copy(showExitDialog = false) }
is MissionContract.Action.RetryPostFortune -> retryPostFortune()
}
}

private fun handleIncreaseCount() = viewModelScope.launch {
private fun handleShake() = viewModelScope.launch {
if (currentState.showOverlay) updateState { copy(showOverlay = false) }
if (currentState.showOverlayText) updateState { copy(showOverlayText = false) }
if (currentState.missionType !is MissionContract.MissionType.Shake) return@launch

val currentCount = currentState.shakeCount
if (currentCount < 9) {
Expand Down Expand Up @@ -92,6 +97,37 @@ class MissionViewModel @Inject constructor(
}
}

private fun handleClick() = viewModelScope.launch {
if (currentState.missionType !is MissionContract.MissionType.Click) return@launch

val currentCount = currentState.clickCount
if (currentCount < 9) {
hapticFeedbackManager.performHapticFeedback(HapticType.SUCCESS)
analyticsHelper.logEvent(
AnalyticsEvent(
type = "mission_success",
properties = mapOf(
AnalyticsEvent.MissionPropertiesKeys.MISSION_TYPE to "click",
),
),
)
updateState { copy(clickCount = currentCount + 1, playWhenClick = true) }

kotlinx.coroutines.delay(500)
updateState { copy(playWhenClick = false) }
} else if (currentCount == 9) {
updateState {
copy(
clickCount = 10,
showFinalAnimation = true,
)
}
postFortune()
kotlinx.coroutines.delay(500)
updateState { copy(isMissionCompleted = true) }
}
}

private fun postFortune() {
viewModelScope.launch {
val userId = userPreferences.userIdFlow.firstOrNull() ?: return@launch
Expand Down