diff --git a/core/compose-ui/src/main/java/com/example/compose_ui/common/LogComposition.kt b/core/compose-ui/src/main/java/com/example/compose_ui/common/LogComposition.kt new file mode 100644 index 00000000..e283bd4f --- /dev/null +++ b/core/compose-ui/src/main/java/com/example/compose_ui/common/LogComposition.kt @@ -0,0 +1,25 @@ +package com.example.compose_ui.common + +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember + +class RecompositionCounter(var value: Int) + +@Composable +inline fun LogComposition( + tag: String, + msg: String = "", +) { + DisposableEffect(Unit) { + onDispose { + Log.d(tag, "Dispose: $msg") + } + } + val recompositionCounter = remember { RecompositionCounter(0) } + SideEffect { recompositionCounter.value++ } + Log.d(tag, "Composition: $msg ${recompositionCounter.value}") +} + diff --git a/feature/tohot/build.gradle.kts b/feature/tohot/build.gradle.kts index 52aea010..b4d89c18 100644 --- a/feature/tohot/build.gradle.kts +++ b/feature/tohot/build.gradle.kts @@ -76,4 +76,5 @@ dependencies { implementation(libs.lottie.compose) implementation(libs.renderscript.intrinsics.replacement.toolkit) + implementation(libs.kotlin.collections.immutable) } diff --git a/feature/tohot/src/main/java/tht/feature/tohot/component/card/ToHotCard.kt b/feature/tohot/src/main/java/tht/feature/tohot/component/card/ToHotCard.kt index 39409f24..5f47e49b 100644 --- a/feature/tohot/src/main/java/tht/feature/tohot/component/card/ToHotCard.kt +++ b/feature/tohot/src/main/java/tht/feature/tohot/component/card/ToHotCard.kt @@ -2,7 +2,6 @@ package tht.feature.tohot.component.card import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -29,11 +28,13 @@ import tht.feature.tohot.component.progress.ToHotHeartTimeProgressContainer import tht.feature.tohot.component.userinfo.ToHotUserInfoCard import tht.feature.tohot.model.CardTimerUiModel import tht.feature.tohot.model.ImmutableListWrapper +import kotlin.time.DurationUnit +import kotlin.time.toDuration -@OptIn(ExperimentalFoundationApi::class) @Composable fun ToHotCard( modifier: Modifier = Modifier, + timer: CardTimerUiModel?, imageUrls: ImmutableListWrapper, name: String, age: Int, @@ -41,16 +42,13 @@ fun ToHotCard( interests: ImmutableListWrapper, idealTypes: ImmutableListWrapper, introduce: String, - timer: CardTimerUiModel.ToHotTimer, - maxTimeSec: Int, - currentSec: Float, - destinationSec: Float, enable: Boolean, fallingAnimationEnable: Boolean = false, isHoldCard: Boolean, isShakingCard: Boolean, onFallingAnimationFinish: () -> Unit = { }, - ticChanged: (Float) -> Unit = { }, + onTicChanged: (Float) -> Unit = { }, + onTimerEnd: () -> Unit = { }, userCardClick: () -> Unit = { }, onLikeClick: () -> Unit = { }, onUnLikeClick: () -> Unit = { }, @@ -92,15 +90,25 @@ fun ToHotCard( val timerModifier = Modifier .align(Alignment.TopCenter) .padding(horizontal = 13.dp, vertical = 12.dp) - when (timer) { + when (timer?.timerType) { CardTimerUiModel.ToHotTimer.Timer -> { ToHotAnimateTimeProgressContainer( modifier = timerModifier, + maxTimer = remember(timer) { + timer.maxTimer.toLong(DurationUnit.SECONDS).toInt() + }, + initialDelay = remember(timer) { + timer.initialDelay.toLong(DurationUnit.MILLISECONDS) + }, + completionDelay = remember(timer) { + timer.completionDelay.toLong(DurationUnit.MILLISECONDS) + }, enable = enable && !isHoldCard, - maxTimeSec = maxTimeSec, - currentSec = currentSec, - ticChanged = ticChanged, - destinationSec = destinationSec + duration = remember(timer) { + timer.duration.toLong(DurationUnit.MILLISECONDS) + }, + onTicChanged = onTicChanged, + onEnd = onTimerEnd ) } @@ -115,6 +123,12 @@ fun ToHotCard( modifier = timerModifier ) } + + null -> { + ToHotEmptyTimeProgressContainer( + modifier = timerModifier + ) + } } if (isHoldCard) return@FallingCard @@ -170,10 +184,13 @@ private fun ToHotCardPreview() { interests = ImmutableListWrapper(emptyList()), idealTypes = ImmutableListWrapper(emptyList()), introduce = "introduce", - timer = CardTimerUiModel.ToHotTimer.Timer, - maxTimeSec = 5, - currentSec = 5f, - destinationSec = 4f, + timer = CardTimerUiModel( + maxTimer = 5.toDuration(DurationUnit.NANOSECONDS), + initialDelay = 1.toDuration(DurationUnit.NANOSECONDS), + completionDelay = 1.toDuration(DurationUnit.NANOSECONDS), + duration = 6.toDuration(DurationUnit.NANOSECONDS), + startAble = true + ), enable = true, isHoldCard = false, isShakingCard = false @@ -198,10 +215,13 @@ private fun ToHotCardHoldCardPreview() { interests = ImmutableListWrapper(emptyList()), idealTypes = ImmutableListWrapper(emptyList()), introduce = "introduce", - timer = CardTimerUiModel.ToHotTimer.Timer, - maxTimeSec = 5, - currentSec = 5f, - destinationSec = 4f, + timer = CardTimerUiModel( + maxTimer = 5.toDuration(DurationUnit.NANOSECONDS), + initialDelay = 1.toDuration(DurationUnit.NANOSECONDS), + completionDelay = 1.toDuration(DurationUnit.NANOSECONDS), + duration = 6.toDuration(DurationUnit.NANOSECONDS), + startAble = true + ), enable = true, isHoldCard = true, isShakingCard = false @@ -226,10 +246,14 @@ private fun ToHotHeartCardPreview() { interests = ImmutableListWrapper(emptyList()), idealTypes = ImmutableListWrapper(emptyList()), introduce = "introduce", - timer = CardTimerUiModel.ToHotTimer.Heart, - maxTimeSec = 5, - currentSec = 5f, - destinationSec = 4f, + timer = CardTimerUiModel( + maxTimer = 5.toDuration(DurationUnit.NANOSECONDS), + initialDelay = 1.toDuration(DurationUnit.NANOSECONDS), + completionDelay = 1.toDuration(DurationUnit.NANOSECONDS), + duration = 6.toDuration(DurationUnit.NANOSECONDS), + startAble = true, + timerType = CardTimerUiModel.ToHotTimer.Heart + ), enable = true, isHoldCard = false, isShakingCard = false @@ -254,10 +278,14 @@ private fun ToHotDislikeCardPreview() { interests = ImmutableListWrapper(emptyList()), idealTypes = ImmutableListWrapper(emptyList()), introduce = "introduce", - timer = CardTimerUiModel.ToHotTimer.Dislike, - maxTimeSec = 5, - currentSec = 5f, - destinationSec = 4f, + timer = CardTimerUiModel( + maxTimer = 5.toDuration(DurationUnit.NANOSECONDS), + initialDelay = 1.toDuration(DurationUnit.NANOSECONDS), + completionDelay = 1.toDuration(DurationUnit.NANOSECONDS), + duration = 6.toDuration(DurationUnit.NANOSECONDS), + startAble = true, + timerType = CardTimerUiModel.ToHotTimer.Dislike + ), enable = true, isHoldCard = false, isShakingCard = false diff --git a/feature/tohot/src/main/java/tht/feature/tohot/component/progress/ToHotAnimateTimeProgressContainer.kt b/feature/tohot/src/main/java/tht/feature/tohot/component/progress/ToHotAnimateTimeProgressContainer.kt index a59468c5..d5e4c910 100644 --- a/feature/tohot/src/main/java/tht/feature/tohot/component/progress/ToHotAnimateTimeProgressContainer.kt +++ b/feature/tohot/src/main/java/tht/feature/tohot/component/progress/ToHotAnimateTimeProgressContainer.kt @@ -1,6 +1,5 @@ package tht.feature.tohot.component.progress -import android.util.Log import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing @@ -9,108 +8,129 @@ import androidx.compose.foundation.layout.padding 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.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.colorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.example.compose_ui.common.LogComposition +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.ceil -/** - * 1. maxTime, currentSec 를 받아 ProgressBar 구성 - * 2. maxTime, destinationSec 로 목표 progress 산출 - * 3. 애니메이션 수행 - * 4. 애니메이션 수행 후 ticChanged 호출 - * - * TODO: Timer 가 tic 마다 끊기는 듯한 UI 문제 확인 - * - Release Build 테스트 - * - Recomposition 최적화 확인 - */ @Composable fun ToHotAnimateTimeProgressContainer( + maxTimer: Int, + duration: Long, + enable: Boolean, + onEnd: () -> Unit, + onTicChanged: (Float) -> Unit, + modifier: Modifier = Modifier, + initialDelay: Long = 0L, + completionDelay: Long = 0L +) { + val coroutineScope = rememberCoroutineScope() + LogComposition("cwj_debug", "ToHotAnimateTimeProgressContainer") + var progressState by remember { mutableStateOf(false) } // disActive + LaunchedEffect(initialDelay, enable) { + if (enable) { + delay(initialDelay) + progressState = true + } + } + if (progressState) { + val destinationSec = 0f + + ToHotAnimateTimeProgressContainerInternal( + enable = enable, + modifier = modifier, + maxTimeSec = maxTimer, + destinationSec = destinationSec, + duration = duration, + onTicChanged = { + coroutineScope.launch { + if (it <= destinationSec) { + if (completionDelay > 0) { + progressState = false + } + delay(completionDelay) + onEnd() + } else { + onTicChanged(it) + } + } + } + ) + } else { + ToHotEmptyTimeProgressContainer(modifier = modifier) + } +} + +@Composable +private fun ToHotAnimateTimeProgressContainerInternal( modifier: Modifier = Modifier, enable: Boolean, maxTimeSec: Int, - currentSec: Float, destinationSec: Float, - progressColor: List = listOf( + duration: Long, + progressColor: ImmutableList = persistentListOf( Color(0xFFF9CC2E), Color(0xFFF98F2E), Color(0xFFF93A2E) ), progressBackgroundColor: Color = colorResource(id = tht.core.ui.R.color.black_353535), - duration: Float = ((currentSec - destinationSec) * 1000), - ticChanged: (Float) -> Unit = { } + onTicChanged: (Float) -> Unit = { } ) { - Log.d("Timer", "cSec[$currentSec], dSec[$destinationSec]") + val progressAnimatable = remember { Animatable(1f) } + var currentSec by remember { mutableIntStateOf(maxTimeSec) } val destinationProgress = destinationSec / maxTimeSec.toFloat() - var color = progressColor.lastOrNull() ?: Color.Yellow - for (i in progressColor.indices) { - val value = progressColor.size - i - 1 - if (destinationProgress >= (1.0f / progressColor.size) * value) { - color = progressColor[i] - break + var color by remember(progressColor) { + mutableStateOf(progressColor.firstOrNull() ?: Color.Yellow) + } + + LaunchedEffect(currentSec) { + for (i in progressColor.indices) { + // currentSec로 하면 색상 변경이 좀 늦어져서, 1초 뒤 변경될 progress 기준으로 계산 + val currentProgress = (currentSec - 1).coerceAtLeast(0).toFloat() / maxTimeSec + val value = progressColor.size - 1 - i + if (currentProgress >= (1.0f / progressColor.size) * value) { + color = progressColor[i] + break + } } } val animateProgressColor by animateColorAsState( targetValue = color, - animationSpec = tween(durationMillis = duration.toInt()), + animationSpec = tween(durationMillis = 1000), label = "animateProgressColor" ) - val progressAnimatable = remember { Animatable((currentSec / maxTimeSec.toFloat())) } LaunchedEffect(key1 = destinationSec, key2 = enable) { + var prevTic = 0 if (enable) { - if (progressAnimatable.targetValue == destinationProgress) { - /** - * Timer Animation 중단 후 재개할 경우, progress 는 줄어 들어 있는 상태 에서 - * duration 은 기존 값대로 유지 되어 Animation 이 빠르게 진행 되는 문제 - * -> 현재 progress 에 걸맞는 새로운 duration 계산 - * - * 1. 한 Tic 동안 줄어 들어야 하는 progress 계산 - * - 총 5초일 경우 1.0 / 5 -> 0.2 - * - * 2. 한 Tic 동안 돌아야 하는 잔여 progress 계산 - * - progress 가 0.85 에서 중단 된 후 재개 된다면 현재 Tic 에서 0.5 progress 진행 필요 - * - (0.85 - (destinationSec[4] * oneTicProgress[0.25])) - * -> destinationSec[4] * oneTicProgress[0.25]) 값은, destinationSec 에 도달 했을 progress[0.8] 을 의미 - * - * 3. 잔여 progress 를 oneTicProgress 에 곱한 후 본래 duration 에 곱해서 잔여 duration 계산 - * - 0.05 / 0.2 => 0.25 - * - 0.25 * 1000 => 250 - */ - val oneTicProgress = 1 / maxTimeSec.toFloat() // 한 틱 동안 줄어 들어야 하는 progress.value - val oneTicRemainingProgress = progressAnimatable.value - (destinationSec * oneTicProgress) - val remainingDuration = oneTicRemainingProgress / oneTicProgress * duration - Log.d( - "Timer remaining", - "progressAnimatable.value[${progressAnimatable.value}], " + - " oneTicRemainingProgress[$oneTicRemainingProgress], " + - "remainingDuration[$remainingDuration]" - ) - progressAnimatable.animateTo( - targetValue = destinationProgress, - animationSpec = tween( - durationMillis = remainingDuration.toInt(), - easing = LinearEasing - ) - ) - } else { - Log.d( - "Timer", - "duration => $duration, progress => $destinationProgress," + - " target : ${progressAnimatable.targetValue}, value : ${progressAnimatable.value}" - ) - progressAnimatable.animateTo( - targetValue = destinationProgress, - animationSpec = tween( - durationMillis = duration.toInt(), - easing = LinearEasing - ) + // 현재 progressValue -> 0.0 까지 필요한 destination 계산 + val progressDuration = duration * (progressAnimatable.value / 1f) + progressAnimatable.animateTo( + targetValue = destinationProgress, + animationSpec = tween( + durationMillis = progressDuration.toInt(), + easing = LinearEasing ) + ) { + currentSec = ceil((this.value * maxTimeSec)).toInt() + if (prevTic != currentSec) { + prevTic = currentSec + onTicChanged(prevTic.toFloat()) + } } - ticChanged((progressAnimatable.value * maxTimeSec)) } } @@ -125,7 +145,7 @@ fun ToHotAnimateTimeProgressContainer( progressColor = animateProgressColor, backgroundColor = progressBackgroundColor, progress = 1 - progressAnimatable.value, - sec = currentSec.toInt() + sec = currentSec ) ToHotTimeProgressBar( @@ -144,9 +164,9 @@ private fun ToHotAnimateTimeProgressContainerPreview() { ToHotAnimateTimeProgressContainer( modifier = Modifier.padding(horizontal = 13.dp, vertical = 12.dp), enable = true, - maxTimeSec = 5, - currentSec = 5f, - ticChanged = {}, - destinationSec = 4f + onEnd = {}, + duration = 1000, + maxTimer = 5, + onTicChanged = {} ) } diff --git a/feature/tohot/src/main/java/tht/feature/tohot/model/CardTimerUiModel.kt b/feature/tohot/src/main/java/tht/feature/tohot/model/CardTimerUiModel.kt index 3d789148..7906731b 100644 --- a/feature/tohot/src/main/java/tht/feature/tohot/model/CardTimerUiModel.kt +++ b/feature/tohot/src/main/java/tht/feature/tohot/model/CardTimerUiModel.kt @@ -1,12 +1,14 @@ package tht.feature.tohot.model import androidx.compose.runtime.Immutable +import kotlin.time.Duration @Immutable data class CardTimerUiModel( - val maxSec: Int, - val currentSec: Float, - val destinationSec: Float, + val maxTimer: Duration, + val initialDelay: Duration, + val completionDelay: Duration, + val duration: Duration, val startAble: Boolean, // card image loading 이 완료 후 timer 실행을 위한 속성 val timerType: ToHotTimer = ToHotTimer.Timer ) { diff --git a/feature/tohot/src/main/java/tht/feature/tohot/tohot/route/ToHotRoute.kt b/feature/tohot/src/main/java/tht/feature/tohot/tohot/route/ToHotRoute.kt index 4de50681..a451f988 100644 --- a/feature/tohot/src/main/java/tht/feature/tohot/tohot/route/ToHotRoute.kt +++ b/feature/tohot/src/main/java/tht/feature/tohot/tohot/route/ToHotRoute.kt @@ -210,7 +210,7 @@ internal fun ToHotRoute( cardList = toHotState.userList, toHotCardState = toHotState.userCardState, pagerState = pagerState, - timers = toHotState.timers, + timer = toHotState.timer, currentUserIdx = toHotState.enableTimerIdx, cardMoveAllow = toHotState.cardMoveAllow, topicIconUrl = toHotState.currentTopic?.iconUrl, @@ -224,7 +224,8 @@ internal fun ToHotRoute( topicSelectListener = toHotViewModel::topicChangeClickEvent, alarmClickListener = toHotViewModel::alarmClickEvent, pageChanged = toHotViewModel::userChangeEvent, - ticChanged = toHotViewModel::ticChangeEvent, + onTimerEnd = toHotViewModel::onTimerEnd, + onTicChanged = toHotViewModel::onTicChanged, loadFinishListener = toHotViewModel::userCardLoadFinishEvent, onLikeClick = toHotViewModel::userHeartEvent, onUnLikeClick = toHotViewModel::userDislikeEvent, diff --git a/feature/tohot/src/main/java/tht/feature/tohot/tohot/screen/ToHotScreen.kt b/feature/tohot/src/main/java/tht/feature/tohot/tohot/screen/ToHotScreen.kt index b1b0ef08..d1a4f6ac 100644 --- a/feature/tohot/src/main/java/tht/feature/tohot/tohot/screen/ToHotScreen.kt +++ b/feature/tohot/src/main/java/tht/feature/tohot/tohot/screen/ToHotScreen.kt @@ -1,6 +1,5 @@ package tht.feature.tohot.tohot.screen -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -15,9 +14,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import tht.feature.tohot.component.card.ToHotCard import tht.feature.tohot.component.card.ToHotEnterCard -import tht.feature.tohot.component.card.ToHotNoneNextUserCard -import tht.feature.tohot.component.card.ToHotNoneInitialUserCard import tht.feature.tohot.component.card.ToHotErrorCard +import tht.feature.tohot.component.card.ToHotNoneInitialUserCard +import tht.feature.tohot.component.card.ToHotNoneNextUserCard import tht.feature.tohot.component.card.ToHotQuerySuccessCard import tht.feature.tohot.component.toolbar.ToHotToolBar import tht.feature.tohot.component.toolbar.ToHotToolBarContent @@ -28,15 +27,16 @@ import tht.feature.tohot.model.ToHotUserUiModel import tht.feature.tohot.tohot.state.ToHotCardState import tht.feature.tohot.tohot.state.ToHotLoading import tht.feature.tohot.tohot.state.ToHotState +import kotlin.time.DurationUnit +import kotlin.time.toDuration -@OptIn(ExperimentalFoundationApi::class) @Composable internal fun ToHotScreen( modifier: Modifier = Modifier, toHotCardState: ToHotCardState, pagerState: PagerState, cardList: ImmutableListWrapper, - timers: ImmutableListWrapper, + timer: CardTimerUiModel, currentUserIdx: Int, cardMoveAllow: Boolean, topicIconUrl: String?, @@ -50,7 +50,8 @@ internal fun ToHotScreen( topicSelectListener: () -> Unit = { }, alarmClickListener: () -> Unit = { }, pageChanged: (Int) -> Unit, - ticChanged: (Float, Int) -> Unit, + onTimerEnd: (Int) -> Unit, + onTicChanged: (Float, Int) -> Unit, onLikeClick: (Int) -> Unit = { }, onUnLikeClick: (Int) -> Unit = { }, onReportMenuClick: () -> Unit = { }, @@ -85,9 +86,19 @@ internal fun ToHotScreen( VerticalPager( userScrollEnabled = false, state = pagerState, - key = { cardList.list[it].id } + key = { + // List 가 업데이트 되기 이전에 PagerState.pageCount 블록이 업데이트 된 ListSize를 리턴해서 IndexOutOfBoundsException 발생 + // 원인 파악을 아직 하지 못해서 임시 방편 처리 + if (it in cardList.list.indices) { + cardList.list[it].id + } else { + it + } + } ) { idx -> val card = cardList.list[idx] + val isCurrentCard = currentUserIdx == pagerState.currentPage && + idx == currentUserIdx ToHotCard( modifier = Modifier .fillMaxSize() @@ -99,19 +110,16 @@ internal fun ToHotScreen( interests = card.interests, idealTypes = card.idealTypes, introduce = card.introduce, - timer = timers.list[idx].timerType, - maxTimeSec = timers.list[idx].maxSec, - currentSec = timers.list[idx].currentSec, - destinationSec = timers.list[idx].destinationSec, - enable = currentUserIdx == pagerState.currentPage && - timers.list[idx].startAble && cardMoveAllow, + timer = if (isCurrentCard) timer else null, + enable = isCurrentCard && timer.startAble && cardMoveAllow, fallingAnimationEnable = idx == fallingAnimationTargetIdx, isHoldCard = isHoldCard, isShakingCard = isShakingCard, onFallingAnimationFinish = { onFallingAnimationFinish(idx) }, userCardClick = { }, onReportMenuClick = onReportMenuClick, - ticChanged = { ticChanged(it, idx) }, + onTicChanged = { onTicChanged(it, idx) }, + onTimerEnd = { onTimerEnd(idx) }, onLikeClick = { onLikeClick(idx) }, onUnLikeClick = { onUnLikeClick(idx) }, loadFinishListener = { s, e -> loadFinishListener(idx, s, e) }, @@ -127,22 +135,18 @@ internal fun ToHotScreen( } } -@OptIn(ExperimentalFoundationApi::class) @Composable @Preview fun ToHotScreenPreview() { val toHotState = ToHotState( userList = ImmutableListWrapper(mockUserList.toList()), userCardState = ToHotCardState.Running, - timers = ImmutableListWrapper( - Array(mockUserList.size) { - CardTimerUiModel( - maxSec = 5, - currentSec = 5f, - destinationSec = 4.5f, - startAble = false - ) - }.toList() + timer = CardTimerUiModel( + maxTimer = 5.toDuration(DurationUnit.NANOSECONDS), + initialDelay = 1.toDuration(DurationUnit.NANOSECONDS), + completionDelay = 1.toDuration(DurationUnit.NANOSECONDS), + duration = 6.toDuration(DurationUnit.NANOSECONDS), + startAble = true ), enableTimerIdx = 0, cardMoveAllow = true, @@ -161,7 +165,7 @@ fun ToHotScreenPreview() { pagerState = rememberPagerState( pageCount = { toHotState.userList.list.size } ), - timers = toHotState.timers, + timer = toHotState.timer, currentUserIdx = toHotState.enableTimerIdx, cardMoveAllow = toHotState.cardMoveAllow, topicIconUrl = toHotState.currentTopic?.iconUrl, @@ -175,7 +179,8 @@ fun ToHotScreenPreview() { topicSelectListener = { }, alarmClickListener = { }, pageChanged = { }, - ticChanged = { _, _ -> }, + onTimerEnd = { }, + onTicChanged = { _, _ -> }, loadFinishListener = { _, _, _ -> }, onLikeClick = { }, onUnLikeClick = { }, diff --git a/feature/tohot/src/main/java/tht/feature/tohot/tohot/state/ToHotState.kt b/feature/tohot/src/main/java/tht/feature/tohot/tohot/state/ToHotState.kt index 1cdf15a9..baf3c924 100644 --- a/feature/tohot/src/main/java/tht/feature/tohot/tohot/state/ToHotState.kt +++ b/feature/tohot/src/main/java/tht/feature/tohot/tohot/state/ToHotState.kt @@ -12,7 +12,7 @@ data class ToHotState( val loading: ToHotLoading, val userList: ImmutableListWrapper, val userCardState: ToHotCardState = ToHotCardState.NoneSelectTopic, // Start, Empty 경우 보여줄 View 를 정함 - val timers: ImmutableListWrapper, + val timer: CardTimerUiModel, // 현재 표시 중인 Card TimerState val enableTimerIdx: Int, // 현재 표시 되는 Card Idx -> 해당 Card 의 Timer 진행됨 val fallingAnimationIdx: Int = -1, // 신고, 차단 Animation Idx val cardMoveAllow: Boolean, // card suspend 기능. false 일 경우 Timer 중단. Dialog 등이 표시 될 때 사용 diff --git a/feature/tohot/src/main/java/tht/feature/tohot/tohot/viewmodel/ToHotViewModel.kt b/feature/tohot/src/main/java/tht/feature/tohot/tohot/viewmodel/ToHotViewModel.kt index f99be11f..acf3c9db 100644 --- a/feature/tohot/src/main/java/tht/feature/tohot/tohot/viewmodel/ToHotViewModel.kt +++ b/feature/tohot/src/main/java/tht/feature/tohot/tohot/viewmodel/ToHotViewModel.kt @@ -36,6 +36,8 @@ import tht.feature.tohot.tohot.state.ToHotSideEffect import tht.feature.tohot.tohot.state.ToHotState import java.util.Stack import javax.inject.Inject +import kotlin.time.DurationUnit +import kotlin.time.toDuration /** * TODO: UseCase Test Code 작성 @@ -55,7 +57,7 @@ class ToHotViewModel @Inject constructor( ) : ViewModel(), Container { private val initializeState get() = ToHotState( userList = ImmutableListWrapper(emptyList()), - timers = ImmutableListWrapper(emptyList()), + timer = createDefaultTimer(), enableTimerIdx = 0, cardMoveAllow = true, loading = ToHotLoading.None, @@ -80,6 +82,8 @@ class ToHotViewModel @Inject constructor( private val fetchUserListPagingResultChannel = Channel() + private val userCardLoadedIdxSet = mutableSetOf() + private val currentUserListRange: IntRange get() = store.state.value.userList.list.indices @@ -111,16 +115,7 @@ class ToHotViewModel @Inject constructor( it.copy( userList = ImmutableListWrapper(newList), userCardState = cardState, - timers = ImmutableListWrapper( - List(toHotState.cards.size) { - CardTimerUiModel( - maxSec = MAX_TIMER_SEC.toInt(), - currentSec = MAX_TIMER_SEC, - destinationSec = MAX_TIMER_SEC, - startAble = false - ) - } - ), + timer = createDefaultTimer(), enableTimerIdx = 0, topicList = ImmutableListWrapper(toHotState.topic.topics.map { t -> t.toUiModel() }), topicModalShow = toHotState.needSelectTopic, @@ -252,7 +247,6 @@ class ToHotViewModel @Inject constructor( reduce { it.copy( userList = ImmutableListWrapper(emptyList()), - timers = ImmutableListWrapper(emptyList()), userCardState = ToHotCardState.NoneNextUser, enableTimerIdx = 0 ) @@ -314,17 +308,7 @@ class ToHotViewModel @Inject constructor( userList = ImmutableListWrapper( store.state.value.userList.list + dailyUserCardList.cards.map { c -> c.toUiModel() } ), - timers = ImmutableListWrapper( - store.state.value.timers.list + - List(dailyUserCardList.cards.size) { - CardTimerUiModel( - maxSec = MAX_TIMER_SEC.toInt(), - currentSec = MAX_TIMER_SEC, - destinationSec = MAX_TIMER_SEC, - startAble = false - ) - } - ), + timer = createDefaultTimer(), topicResetRemainingTime = parseRemainingTime(dailyUserCardList.topicResetTimeMill), topicResetTimeMill = dailyUserCardList.topicResetTimeMill ) @@ -354,17 +338,6 @@ class ToHotViewModel @Inject constructor( store.state.value.userList.list + dailyUserCardList.cards.map { c -> c.toUiModel() } ), userCardState = ToHotCardState.Running, - timers = ImmutableListWrapper( - store.state.value.timers.list + - List(dailyUserCardList.cards.size) { - CardTimerUiModel( - maxSec = MAX_TIMER_SEC.toInt(), - currentSec = MAX_TIMER_SEC, - destinationSec = MAX_TIMER_SEC, - startAble = false - ) - } - ), enableTimerIdx = if (pagingLoading) it.enableTimerIdx else 0, loading = ToHotLoading.None, topicResetRemainingTime = parseRemainingTime(dailyUserCardList.topicResetTimeMill), @@ -462,7 +435,7 @@ class ToHotViewModel @Inject constructor( * 중복 데이터 처리를 위해 passedCardIdSet 추가 */ fun userChangeEvent(userIdx: Int) { - Log.d("ToHot", "userChangeEvent => $userIdx") + Log.d(TAG, "userChangeEvent => $userIdx") if (userIdx !in currentUserListRange) return with(store.state.value) { if (!passedCardIdSet.contains(userList.list[userIdx].id)) { @@ -479,16 +452,10 @@ class ToHotViewModel @Inject constructor( intent { reduce { it.copy( - timers = ImmutableListWrapper( - it.timers.list.toMutableList().apply { - this[userIdx] = this[userIdx].copy( - maxSec = MAX_TIMER_SEC.toInt(), - currentSec = MAX_TIMER_SEC, - destinationSec = MAX_TIMER_SEC - TIMER_INTERVAL - ) - } - ), enableTimerIdx = userIdx, + timer = createDefaultTimer( + startAble = userCardLoadedIdxSet.contains(userIdx) + ), cardMoveAllow = passedCardCountBetweenTouch <= CARD_COUNT_ALLOW_WITHOUT_TOUCH && it.matchingFullScreenUser == null, reportMenuDialogShow = false, @@ -502,55 +469,47 @@ class ToHotViewModel @Inject constructor( } fun userCardLoadFinishEvent(idx: Int, result: Boolean, error: Throwable?) { - Log.d("TAG", "userCardLoadFinishEvent => $idx, $result") + Log.d(TAG, "userCardLoadFinishEvent => $idx, $result") error?.printStackTrace() + userCardLoadedIdxSet.add(idx) intent { reduce { it.copy( - timers = ImmutableListWrapper( - it.timers.list.toMutableList().apply { - this[idx] = this[idx].copy( - startAble = true - ) - } - ) + timer = createDefaultTimer(startAble = true) ) } } } - /** - * timer tic 이 변경될 때 호출 - * - timer 가 0이면 다음 유저 스크롤 - * - timer 가 0이 아니면 timer 를 1 감소 - */ - fun ticChangeEvent(tic: Float, userIdx: Int) = with(store.state.value) { - Log.d("Timer", "ticChangeEvent => $tic from $userIdx => enableTimerIdx[$enableTimerIdx]") + fun onTimerEnd(userIdx: Int) = with(store.state.value) { + Log.d("Timer", "onTimerEnd => $userIdx => enableTimerIdx[$enableTimerIdx]") if (userIdx != enableTimerIdx) return@with + tryScrollToNext(userIdx) + } + + fun onTicChanged(tic: Float, userIdx: Int) = with(store.state.value) { + Log.d("Timer", "ticChangeEvent => $tic from $userIdx => enableTimerIdx[$enableTimerIdx]") + if (userIdx != enableTimerIdx) return + if (userIdx !in userList.list.indices) return if (tic <= 0) { - tryScrollToNext(userIdx) + onTimerEnd(userIdx) return } - if (userIdx !in userList.list.indices) return - intent { - reduce { - it.copy( - timers = ImmutableListWrapper( - it.timers.list.toMutableList().apply { - this[userIdx] = this[userIdx].copy( - currentSec = this[userIdx].destinationSec, - destinationSec = this[userIdx].destinationSec - TIMER_INTERVAL - ) - } - ), - shakingCard = tic <= SHAKING_ANIMATION_START_TIC - ) + if (tic <= SHAKING_ANIMATION_START_TIC) { + intent { + reduce { + it.copy(shakingCard = true) + } } } } fun userHeartEvent(idx: Int) { - if (heartLoading || store.state.value.currentTopic == null) return + if (heartLoading) return + if (store.state.value.currentTopic == null) { + // TODO: Toast + return + } viewModelScope.launch { heartLoading = true sendHeartUseCase( @@ -567,10 +526,8 @@ class ToHotViewModel @Inject constructor( intent { reduce { it.copy( - timers = ImmutableListWrapper( - it.timers.list.toMutableList().apply { - this[idx] = this[idx].copy(timerType = CardTimerUiModel.ToHotTimer.Heart) - } + timer = createDefaultTimer( + timerType = CardTimerUiModel.ToHotTimer.Heart ), shakingCard = false ) @@ -583,6 +540,10 @@ class ToHotViewModel @Inject constructor( fun userDislikeEvent(idx: Int) { if (heartLoading) return + if (store.state.value.currentTopic == null) { + // TODO: Toast + return + } viewModelScope.launch { heartLoading = true sendDislikeUseCase( @@ -599,10 +560,8 @@ class ToHotViewModel @Inject constructor( intent { reduce { it.copy( - timers = ImmutableListWrapper( - it.timers.list.toMutableList().apply { - this[idx] = this[idx].copy(timerType = CardTimerUiModel.ToHotTimer.Dislike) - } + timer = createDefaultTimer( + timerType = CardTimerUiModel.ToHotTimer.Dislike ), shakingCard = false ) @@ -825,9 +784,6 @@ class ToHotViewModel @Inject constructor( userList = ImmutableListWrapper( it.userList.list.toMutableList().apply { removeAt(userIdx) } ), - timers = ImmutableListWrapper( - it.timers.list.toMutableList().apply { removeAt(userIdx) } - ), enableTimerIdx = if (enableTimerIdx >= userIdx) { enableTimerIdx - 1 } else { @@ -886,10 +842,30 @@ class ToHotViewModel @Inject constructor( } } + private fun createDefaultTimer( + startAble: Boolean = false, + timerType: CardTimerUiModel.ToHotTimer = CardTimerUiModel.ToHotTimer.Timer + ): CardTimerUiModel { + return CardTimerUiModel( + maxTimer = MAX_TIMER_MILL.toDuration(DurationUnit.MILLISECONDS), + initialDelay = TIMER_INITIAL_DELAY_MILL.toDuration(DurationUnit.MILLISECONDS), + completionDelay = TIMER_COMPLETION_DELAY_MILL.toDuration(DurationUnit.MILLISECONDS), + duration = TIMER_DURATION_MILL.toDuration(DurationUnit.MILLISECONDS), + startAble = startAble, + timerType = timerType + ) + } + companion object { - private const val MAX_TIMER_SEC = 5f + private const val TAG = "TO_HOT" + + private const val MAX_TIMER_MILL = 5000L + + private const val TIMER_INITIAL_DELAY_MILL = 1000L + + private const val TIMER_COMPLETION_DELAY_MILL = 1000L - private const val TIMER_INTERVAL = 1f + private const val TIMER_DURATION_MILL = 6000L private const val SHAKING_ANIMATION_START_TIC = 3f