diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt index bd5b0c9d..7733e27f 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt @@ -4,25 +4,37 @@ import android.Manifest import android.content.Context import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -34,6 +46,7 @@ import com.twix.designsystem.components.toast.model.ToastData import com.twix.designsystem.components.toast.model.ToastType import com.twix.designsystem.extension.showCameraPermissionToastWithNavigateToSettingAction import com.twix.designsystem.theme.CommonColor +import com.twix.designsystem.theme.GrayColor import com.twix.designsystem.theme.TwixTheme import com.twix.domain.model.enums.BetweenUs import com.twix.domain.model.enums.GoalReactionType @@ -54,6 +67,7 @@ import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject import java.time.LocalDate +import kotlin.math.roundToInt @Composable fun PhotologDetailRoute( @@ -138,6 +152,7 @@ fun PhotologDetailRoute( screenHeightPx = screenHeightPx, onBack = navigateToBack, onRetry = { viewModel.dispatch(PhotologDetailIntent.Retry) }, + onRefresh = { viewModel.dispatch(PhotologDetailIntent.Refresh) }, onClickModify = { navigateToEditor( uiState.goalId, @@ -181,6 +196,7 @@ fun PhotologDetailScreen( screenHeightPx: Float, onBack: () -> Unit, onRetry: () -> Unit, + onRefresh: () -> Unit, onClickModify: () -> Unit, onClickReaction: (GoalReactionType) -> Unit, onClickUpload: () -> Unit, @@ -216,26 +232,44 @@ fun PhotologDetailScreen( onBack = onBack, onClickModify = onClickModify, ) - Spacer(Modifier.height(103.dp)) - PhotologCardContent( - uiState = uiState, - isPokeDisabled = uiState.isPokeDisabled, - onSwipe = onSwipe, - onClickUpload = onClickUpload, - onPoke = onPoke, - ) + PhotologDetailRefreshContent( + modifier = Modifier.weight(1f), + isRefreshing = uiState.isRefreshing, + onRefresh = onRefresh, + ) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val cardSize = maxWidth - 54.dp + val reactionTopPadding = 103.dp + cardSize + 85.dp - if (uiState.canReaction) { - ReactionContent( - screenHeightPx = screenHeightPx, - reaction = uiState.partnerPhotolog?.reaction, - onClickReaction = onClickReaction, - ) + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(103.dp)) + + PhotologCardContent( + uiState = uiState, + isPokeDisabled = uiState.isPokeDisabled, + onSwipe = onSwipe, + onClickUpload = onClickUpload, + onPoke = onPoke, + ) + } + + if (uiState.canReaction) { + ReactionContent( + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = reactionTopPadding), + screenHeightPx = screenHeightPx, + reaction = uiState.partnerPhotolog?.reaction, + onClickReaction = onClickReaction, + ) + } + } } } - if (uiState.showOverlayLoading || uiState.isPoking) { + if (uiState.showContentLoading) { TwixLoadingOverlay() } } @@ -243,6 +277,138 @@ fun PhotologDetailScreen( } } +/** + * 인증샷 상세 화면의 TopBar 하단 영역에 당겨서 새로고침 인터랙션을 제공한다. + * + * 상세 화면은 LazyColumn처럼 스크롤 가능한 자식이 없어 nested scroll 체인으로 드래그 이벤트를 + * 안정적으로 받을 수 없다. 그래서 이 컨테이너가 세로 드래그를 직접 감지해 아래로 당긴 거리만큼 + * 콘텐츠와 인디케이터를 함께 이동시키고, 기준 거리 이상에서 손을 떼면 [onRefresh]를 호출한다. + * + * [isRefreshing]이 true인 동안에는 콘텐츠를 고정 거리만큼 유지해 새로고침 진행 상태를 보여주고, + * 완료되면 누적된 당김 offset을 초기화한다. + */ +@Composable +private fun PhotologDetailRefreshContent( + modifier: Modifier = Modifier, + isRefreshing: Boolean, + onRefresh: () -> Unit, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + + // 새로고침 실행 기준 + val refreshTriggerPx = with(density) { 88.dp.toPx() } + // 새로고침 중 유지 거리 + val refreshingHoldPx = with(density) { 56.dp.toPx() } + // 과도한 당김을 제한하는 값 + val maxPullPx = with(density) { 140.dp.toPx() } + + var pullOffsetPx by remember { mutableFloatStateOf(0f) } + + // 사용자 드래그 중에는 누적 offset을 따라가고, 새로고침 중에는 hold 위치에 고정한다. + val animatedPullOffsetPx by animateFloatAsState( + targetValue = + when { + isRefreshing -> refreshingHoldPx + else -> pullOffsetPx + }, + animationSpec = spring(), + label = "photolog_detail_pull_offset", + ) + + LaunchedEffect(isRefreshing) { + if (!isRefreshing) { + // 새로고침이 끝나면 다음 당김을 위해 누적 offset을 초기화한다. + pullOffsetPx = 0f + } + } + + Box( + modifier = + modifier + .fillMaxSize() + .pointerInput(isRefreshing) { + detectVerticalDragGestures( + onVerticalDrag = { change, dragAmount -> + if (dragAmount > 0 && !isRefreshing) { + // 아래로 당길 때는 저항감을 주기 위해 실제 드래그 거리의 절반만 반영한다. + val newOffset = + (pullOffsetPx + (dragAmount * 0.5f)) + .coerceAtMost(maxPullPx) + pullOffsetPx = newOffset + change.consume() + } + + if (dragAmount < 0 && pullOffsetPx > 0f) { + // 손가락을 위로 되돌릴 때는 누적된 당김 offset만큼만 다시 줄인다. + pullOffsetPx = (pullOffsetPx + dragAmount).coerceAtLeast(0f) + change.consume() + } + }, + onDragEnd = { + // 기준 거리 이상 당긴 뒤 손을 떼면 새로고침을 시작하고, 부족하면 원위치로 복귀한다. + if (pullOffsetPx >= refreshTriggerPx && !isRefreshing) { + onRefresh() + } else if (!isRefreshing) { + pullOffsetPx = 0f + } + }, + onDragCancel = { + if (!isRefreshing) { + pullOffsetPx = 0f + } + }, + ) + }, + ) { + Column( + Modifier + .fillMaxSize() + .offset { + IntOffset( + x = 0, + y = animatedPullOffsetPx.roundToInt(), + ) + }.background(color = CommonColor.White), + ) { + content() + } + + // 당김 진행률만큼 indicator track을 점진적으로 드러낸다. + val indicatorAlpha = + if (animatedPullOffsetPx <= 0f) { + 0f + } else { + (animatedPullOffsetPx / refreshTriggerPx).coerceIn(0f, 1f) + } + + Box( + modifier = + Modifier + .align(Alignment.TopCenter) + .offset { + IntOffset( + x = 0, + y = + ((animatedPullOffsetPx - with(density) { 32.dp.toPx() }) / 2f) + .coerceAtLeast(0f) + .roundToInt(), + ) + }, + contentAlignment = Alignment.Center, + ) { + if (animatedPullOffsetPx > 0f || isRefreshing) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.5.dp, + color = GrayColor.C500, + trackColor = GrayColor.C100.copy(alpha = indicatorAlpha), + ) + } + } + } +} + private fun formatCooldownTime( context: Context, cooldownTime: CooldownTime, @@ -281,6 +447,7 @@ private fun PhotologDetailScreenPreview( screenHeightPx = 0f, onBack = {}, onRetry = {}, + onRefresh = {}, onClickModify = {}, onClickReaction = {}, onClickUpload = {}, diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt index 4df17581..cb28fba8 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt @@ -69,11 +69,13 @@ class PhotologDetailViewModel( checkPokeCooldown() } - private fun fetchPhotolog() { + private fun fetchPhotolog(isUserRefresh: Boolean = false) { launchResult( + onStart = { reduce { copy(isRefreshing = isUserRefresh) } }, + onFinally = { reduce { copy(isRefreshing = false) } }, + onError = { handleFetchPhotologError() }, block = ::fetchPhotologs, onSuccess = ::handleFetchPhotologSuccess, - onError = { handleFetchPhotologError() }, ) } @@ -88,6 +90,7 @@ class PhotologDetailViewModel( argTargetDate, argIsCompleted, ).copy( + currentShow = currentState.currentShow, hasShownMyReaction = currentState.hasShownMyReaction, pokeCooldownRemaining = currentState.pokeCooldownRemaining, ) @@ -149,6 +152,7 @@ class PhotologDetailViewModel( override suspend fun handleIntent(intent: PhotologDetailIntent) { when (intent) { PhotologDetailIntent.Retry -> fetchPhotolog() + PhotologDetailIntent.Refresh -> fetchPhotolog(isUserRefresh = true) is PhotologDetailIntent.Reaction -> reduceReaction(intent.type) PhotologDetailIntent.Poke -> pokeToPartner() PhotologDetailIntent.SwipeCard -> reduceShownCard() diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/component/reaction/ReactionContent.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/component/reaction/ReactionContent.kt index 4e9a2c11..d525f7e8 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/component/reaction/ReactionContent.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/component/reaction/ReactionContent.kt @@ -18,6 +18,7 @@ import com.twix.domain.model.enums.GoalReactionType @Composable internal fun ReactionContent( screenHeightPx: Float, + modifier: Modifier = Modifier, reaction: GoalReactionType? = null, onClickReaction: (GoalReactionType) -> Unit, ) { @@ -25,9 +26,8 @@ internal fun ReactionContent( Box( modifier = - Modifier - .fillMaxSize() - .padding(top = 85.dp), + modifier + .fillMaxSize(), ) { ReactionBar( selectedReaction = reaction, diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailIntent.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailIntent.kt index 13f88212..5afaec7b 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailIntent.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailIntent.kt @@ -6,6 +6,8 @@ import com.twix.ui.base.Intent sealed interface PhotologDetailIntent : Intent { data object Retry : PhotologDetailIntent + data object Refresh : PhotologDetailIntent + data class Reaction( val type: GoalReactionType, ) : PhotologDetailIntent diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailUiState.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailUiState.kt index 5ac3ff55..f929ca40 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailUiState.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailUiState.kt @@ -27,6 +27,7 @@ data class PhotologDetailUiState( * 내 인증샷에 상대방이 리액션을 남겼을 경우 최초 1회 인터렉션 렌더링을 위한 변수 */ val hasShownMyReaction: Boolean = false, + val isRefreshing: Boolean = false, override val isLoading: Boolean = true, override val error: AppError? = null, /** @@ -41,6 +42,9 @@ data class PhotologDetailUiState( val isPokeDisabled: Boolean get() = isPoking || pokeCooldownRemaining > 0 + val showContentLoading: Boolean + get() = (showOverlayLoading && !isRefreshing) || isPoking + /** * 현재 [currentShow]에 해당하는 사용자의 인증샷 인증 여부 * @@ -67,47 +71,6 @@ data class PhotologDetailUiState( BetweenUs.PARTNER -> partnerPhotolog?.uploadedAt } - /** - * 현재 [currentShow]에 해당하는 인증샷 URL - * - * - [BetweenUs.ME]: 내 인증샷 이미지 URL - * - [BetweenUs.PARTNER]: 파트너 인증샷 이미지 URL - * - */ - val displayedGoalImageUrl: String? - get() = - when (currentShow) { - BetweenUs.ME -> myPhotolog?.imageUrl - BetweenUs.PARTNER -> partnerPhotolog?.imageUrl - } - - /** - * 현재 [currentShow]에 해당하는 인증샷의 코멘트 - * - * - [BetweenUs.ME]: 내 코멘트 - * - [BetweenUs.PARTNER]: 파트너 코멘트 - * - */ - val displayedGoalComment: String? - get() = - when (currentShow) { - BetweenUs.ME -> myPhotolog?.comment - BetweenUs.PARTNER -> partnerPhotolog?.comment - } - - /** - * 현재 [currentShow]에 해당하는 사용자의 닉네임 - * - * - [BetweenUs.ME]: 내 닉네임 - * - [BetweenUs.PARTNER]: 파트너 닉네임 - */ - val displayedNickname: String - get() = - when (currentShow) { - BetweenUs.ME -> myNickname - BetweenUs.PARTNER -> partnerNickname - } - /** * 현재 화면이 내 인증샷을 표시하는 상태인지 여부 *