diff --git a/app/src/androidTest/java/com/runnect/runnect/presentation/countdown/CountDownScreenTest.kt b/app/src/androidTest/java/com/runnect/runnect/presentation/countdown/CountDownScreenTest.kt new file mode 100644 index 000000000..52c922649 --- /dev/null +++ b/app/src/androidTest/java/com/runnect/runnect/presentation/countdown/CountDownScreenTest.kt @@ -0,0 +1,57 @@ +package com.runnect.runnect.presentation.countdown + +import androidx.compose.ui.test.assertContentDescriptionEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import com.runnect.runnect.presentation.ui.theme.RunnectTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class CountDownScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun 카운트다운_배경_숫자_안내문구가_노출된다() { + composeTestRule.setContent { + RunnectTheme { + CountDownContent(count = 3) + } + } + + composeTestRule.onNodeWithTag(CountDownScreenTestTags.BACKGROUND).assertIsDisplayed() + composeTestRule.onNodeWithTag(CountDownScreenTestTags.NUMBER) + .assertIsDisplayed() + .assertContentDescriptionEquals("3") + composeTestRule.onNodeWithText("잠시 후 러닝을 시작합니다").assertIsDisplayed() + } + + @Test + fun 카운트다운이_끝나면_완료_콜백이_호출된다() { + var finishedCount = 0 + composeTestRule.mainClock.autoAdvance = false + + composeTestRule.setContent { + RunnectTheme { + CountDownRoute( + onFinished = { finishedCount += 1 } + ) + } + } + + composeTestRule.waitForIdle() + repeat(3) { + composeTestRule.mainClock.advanceTimeBy(CountDownStateMachine.TICK_MILLIS) + composeTestRule.waitForIdle() + } + composeTestRule.waitUntil(timeoutMillis = 5_000L) { + finishedCount == 1 + } + + assertEquals(1, finishedCount) + } +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownActivity.kt b/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownActivity.kt index c8b5d0e7a..efd10937a 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownActivity.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownActivity.kt @@ -1,24 +1,18 @@ package com.runnect.runnect.presentation.countdown import android.content.Intent -import android.graphics.drawable.Drawable import android.os.Bundle -import android.view.animation.Animation -import android.view.animation.Animation.AnimationListener -import android.view.animation.AnimationUtils -import androidx.appcompat.content.res.AppCompatResources -import com.runnect.runnect.R -import com.runnect.runnect.binding.BindingActivity +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity import com.runnect.runnect.data.dto.CourseData -import com.runnect.runnect.databinding.ActivityCountDownBinding import com.runnect.runnect.presentation.run.RunActivity +import com.runnect.runnect.presentation.ui.theme.RunnectTheme import com.runnect.runnect.util.analytics.Analytics import com.runnect.runnect.util.analytics.EventName import com.runnect.runnect.util.analytics.EventName.Param import com.runnect.runnect.util.extension.getCompatibleParcelableExtra -import timber.log.Timber -class CountDownActivity: BindingActivity(R.layout.activity_count_down) { +class CountDownActivity : AppCompatActivity() { private val courseData: CourseData? by lazy { intent.getCompatibleParcelableExtra(EXTRA_COURSE_DATA) } override fun onCreate(savedInstanceState: Bundle?) { @@ -29,64 +23,39 @@ class CountDownActivity: BindingActivity(R.layout.acti Param.COURSE_ID to courseData?.courseId ) - val intentToRun = Intent(this, RunActivity::class.java) - val numList = arrayListOf( - AppCompatResources.getDrawable(this, R.drawable.anim_num1), - AppCompatResources.getDrawable(this, R.drawable.anim_num2) - ) - val anim = AnimationUtils.loadAnimation(this, R.anim.anim_count) - setAnimationListener(anim, numList, intentToRun) - binding.ivCountDown.startAnimation(anim) + setContent { + RunnectTheme { + CountDownRoute( + onFinished = ::moveToRun + ) + } + } } + @Deprecated("Deprecated in Java") override fun onBackPressed() { Analytics.logEvent( EventName.CLICK_CANCEL_COUNTDOWN, Param.COURSE_ID to courseData?.courseId ) finish() - overridePendingTransition(R.anim.slide_in_left, R.anim.slide_out_right) + overridePendingTransition( + com.runnect.runnect.R.anim.slide_in_left, + com.runnect.runnect.R.anim.slide_out_right + ) } - private fun setAnimationListener( - anim: Animation, - numList: ArrayList, - intentToRun: Intent, - ) { - var counter = COUNT_START - - anim.setAnimationListener(object : AnimationListener { - override fun onAnimationStart(animation: Animation) { - } - - override fun onAnimationEnd(animation: Animation) { - counter -= COUNT_DECREASE_UNIT - if (counter == COUNT_END) { - courseData?.let { courseData -> - intentToRun.apply { - putExtra( - EXTRA_COUNTDOWN_TO_RUN, courseData - ) - } - } - startActivity(intentToRun) - finish() - } else { - binding.ivCountDown.post { - binding.ivCountDown.setImageDrawable(numList[counter]) - binding.ivCountDown.startAnimation(animation) - } - } - } - override fun onAnimationRepeat(animation: Animation) {} - }) + private fun moveToRun() { + val intentToRun = Intent(this, RunActivity::class.java) + courseData?.let { courseData -> + intentToRun.putExtra(EXTRA_COUNTDOWN_TO_RUN, courseData) + } + startActivity(intentToRun) + finish() } companion object { - const val COUNT_START = 2 - const val COUNT_END = -1 - const val COUNT_DECREASE_UNIT = 1 const val EXTRA_COUNTDOWN_TO_RUN = "CountToRunData" const val EXTRA_COURSE_DATA = "CourseData" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownScreen.kt new file mode 100644 index 000000000..b1f92de0d --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownScreen.kt @@ -0,0 +1,147 @@ +package com.runnect.runnect.presentation.countdown + +import androidx.annotation.DrawableRes +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import com.runnect.runnect.R +import com.runnect.runnect.presentation.ui.theme.RunnectTheme +import com.runnect.runnect.presentation.ui.theme.White +import kotlinx.coroutines.delay +import kotlin.math.PI +import kotlin.math.cos + +object CountDownScreenTestTags { + const val BACKGROUND = "count_down_background" + const val NUMBER = "count_down_number" + const val DESCRIPTION = "count_down_description" +} + +object CountDownStateMachine { + const val INITIAL_COUNT = 3 + private const val LAST_VISIBLE_COUNT = 1 + const val TICK_MILLIS = 1_000L + + fun nextCount(currentCount: Int): Int? = + if (currentCount > LAST_VISIBLE_COUNT) currentCount - 1 else null + + @DrawableRes + fun numberDrawableRes(count: Int): Int = when (count) { + 3 -> R.drawable.anim_num3 + 2 -> R.drawable.anim_num2 + 1 -> R.drawable.anim_num1 + else -> error("Unsupported countdown number: $count") + } +} + +object CountDownAnimationSpec { + const val INITIAL_SCALE = 0.4f + const val TARGET_SCALE = 1f + + val AccelerateDecelerateEasing = Easing { fraction -> + (cos((fraction + 1f) * PI).toFloat() / 2f) + 0.5f + } +} + +@Composable +fun CountDownRoute( + onFinished: () -> Unit, + modifier: Modifier = Modifier, +) { + var currentCount by remember { mutableIntStateOf(CountDownStateMachine.INITIAL_COUNT) } + var isFinished by remember { mutableStateOf(false) } + val lifecycle = LocalLifecycleOwner.current.lifecycle + + LaunchedEffect(lifecycle) { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (!isFinished) { + delay(CountDownStateMachine.TICK_MILLIS) + val nextCount = CountDownStateMachine.nextCount(currentCount) + if (nextCount == null) { + isFinished = true + onFinished() + } else { + currentCount = nextCount + } + } + } + } + + CountDownContent( + count = currentCount, + modifier = modifier + ) +} + +@Composable +fun CountDownContent( + count: Int, + modifier: Modifier = Modifier, +) { + val scale = remember { Animatable(CountDownAnimationSpec.INITIAL_SCALE) } + + LaunchedEffect(count) { + scale.snapTo(CountDownAnimationSpec.INITIAL_SCALE) + scale.animateTo( + targetValue = CountDownAnimationSpec.TARGET_SCALE, + animationSpec = tween( + durationMillis = CountDownStateMachine.TICK_MILLIS.toInt(), + easing = CountDownAnimationSpec.AccelerateDecelerateEasing + ) + ) + } + + Box( + modifier = modifier.fillMaxSize() + ) { + Image( + painter = painterResource(R.drawable.star_background), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .testTag(CountDownScreenTestTags.BACKGROUND) + ) + Image( + painter = painterResource(CountDownStateMachine.numberDrawableRes(count)), + contentDescription = count.toString(), + modifier = Modifier + .align(Alignment.BottomCenter) + .offset(y = (-350).dp) + .scale(scale.value) + .testTag(CountDownScreenTestTags.NUMBER) + ) + Text( + text = stringResource(R.string.count_down_desc), + style = RunnectTheme.textStyle.medium15, + color = White, + modifier = Modifier + .align(Alignment.BottomCenter) + .offset(y = (-280).dp) + .testTag(CountDownScreenTestTags.DESCRIPTION) + ) + } +} diff --git a/app/src/main/res/anim/anim_count.xml b/app/src/main/res/anim/anim_count.xml deleted file mode 100644 index d539a3eaf..000000000 --- a/app/src/main/res/anim/anim_count.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_count_down.xml b/app/src/main/res/layout/activity_count_down.xml deleted file mode 100644 index 39267477a..000000000 --- a/app/src/main/res/layout/activity_count_down.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/test/java/com/runnect/runnect/presentation/countdown/CountDownStateMachineTest.kt b/app/src/test/java/com/runnect/runnect/presentation/countdown/CountDownStateMachineTest.kt new file mode 100644 index 000000000..e93a883d4 --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/countdown/CountDownStateMachineTest.kt @@ -0,0 +1,34 @@ +package com.runnect.runnect.presentation.countdown + +import com.runnect.runnect.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CountDownStateMachineTest { + + @Test + fun `카운트다운은 3에서 시작한다`() { + assertEquals(3, CountDownStateMachine.INITIAL_COUNT) + } + + @Test + fun `카운트다운은 3_2_1 순서로 진행되고 이후 종료된다`() { + assertEquals(2, CountDownStateMachine.nextCount(3)) + assertEquals(1, CountDownStateMachine.nextCount(2)) + assertNull(CountDownStateMachine.nextCount(1)) + } + + @Test + fun `카운트다운 숫자에 맞는 drawable을 반환한다`() { + assertEquals(R.drawable.anim_num3, CountDownStateMachine.numberDrawableRes(3)) + assertEquals(R.drawable.anim_num2, CountDownStateMachine.numberDrawableRes(2)) + assertEquals(R.drawable.anim_num1, CountDownStateMachine.numberDrawableRes(1)) + } + + @Test + fun `카운트다운 숫자 애니메이션은 기존 XML scale 범위를 유지한다`() { + assertEquals(0.4f, CountDownAnimationSpec.INITIAL_SCALE) + assertEquals(1f, CountDownAnimationSpec.TARGET_SCALE) + } +}