diff --git a/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt b/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt index 0f149a3b..e59dab7c 100644 --- a/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt +++ b/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt @@ -36,7 +36,9 @@ import com.gamss.android.app.navigation.topLevelDestinations import com.gamss.android.core.designsystem.component.GamssBottomBar import com.gamss.android.core.designsystem.component.GamssPaperBackground import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.feature.archive.ArchiveDetailScreen import com.gamss.android.feature.archive.ArchiveScreen +import com.gamss.android.feature.archive.navigation.ArchiveDetailKey import com.gamss.android.feature.archive.navigation.ArchiveKey import com.gamss.android.feature.carddelete.CardDeleteScreen import com.gamss.android.feature.carddelete.navigation.CardDeleteKey @@ -125,6 +127,16 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { entry { ArchiveScreen( onNavigateToSetting = { navigator.navigate(SettingKey) }, + onArchiveClick = { navigator.navigate(ArchiveDetailKey(it)) }, + ) + } + // 보관함 상세도 탭 안쪽의 상세 화면이라 다른 상세들과 같은 슬라이드를 쓴다. + entry(metadata = detailSlideTransition) { key -> + ArchiveDetailScreen( + emotion = key.emotion, + onBackClick = navigator::goBack, + onOpenConversation = { conversationId -> navigator.navigate(ChatRoomKey(conversationId)) }, + onNavigateToCardDelete = { navigator.navigate(CardDeleteKey) }, ) } entry(metadata = detailSlideTransition) { diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt new file mode 100644 index 00000000..aabad586 --- /dev/null +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt @@ -0,0 +1,302 @@ +package com.gamss.android.feature.archive + +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.gamss.android.core.designsystem.button.GamssButtonVariant +import com.gamss.android.core.designsystem.component.GamssIconButton +import com.gamss.android.core.designsystem.component.GamssTopBar +import com.gamss.android.core.designsystem.dialog.GamssDialog +import com.gamss.android.core.designsystem.dialog.GamssDialogAction +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.domain.card.Card +import com.gamss.android.domain.card.CardEntry +import com.gamss.android.domain.emotion.EmotionCharacter +import com.gamss.android.feature.archive.component.CardDetailDialog +import com.gamss.android.feature.archive.component.MonthSelector +import com.gamss.android.feature.archive.component.PaperPile +import com.gamss.android.feature.archive.component.YearMonthPickerSheet +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect +import java.time.LocalDate +import java.time.YearMonth +import com.gamss.android.core.designsystem.R as DesignSystemR + +/** + * 시스템 뒤로가기는 여기서 BackHandler 로 받지 않는다. 먼저 가로채면 NavDisplay 의 onBack 에 닿지 + * 않아, predictive back 미리보기와 pop 트랜지션이 이 화면에서만 빠진다. + */ +@Composable +fun ArchiveDetailScreen( + emotion: EmotionCharacter, + onBackClick: () -> Unit, + onOpenConversation: (Long) -> Unit, + onNavigateToCardDelete: () -> Unit, + viewModel: ArchiveDetailViewModel = hiltViewModel(), +) { + val state by viewModel.collectAsState() + val context = LocalContext.current + val shareChooserTitle = stringResource(R.string.archive_card_share_chooser_title) + val cardLoadFailedMessage = stringResource(R.string.archive_card_load_error) + val discardFailedMessage = stringResource(R.string.archive_card_discard_failure) + + LaunchedEffect(emotion) { viewModel.load(emotion) } + + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is ArchiveDetailSideEffect.OpenChatRoom -> onOpenConversation(sideEffect.conversationId) + ArchiveDetailSideEffect.OpenCardDelete -> onNavigateToCardDelete() + + ArchiveDetailSideEffect.CardLoadFailed -> + Toast.makeText(context, cardLoadFailedMessage, Toast.LENGTH_SHORT).show() + + ArchiveDetailSideEffect.CardDiscardFailed -> + Toast.makeText(context, discardFailedMessage, Toast.LENGTH_SHORT).show() + } + } + + ArchiveDetailFrame( + emotion = emotion, + state = state, + onBackClick = onBackClick, + onPaperClick = viewModel::selectCard, + onMonthClick = viewModel::showMonthPicker, + onClearClick = viewModel::showClearDialog, + ) + + ArchiveDetailOverlays( + state = state, + onMonthSelect = viewModel::selectMonth, + onMonthPickerDismiss = viewModel::dismissMonthPicker, + onClearConfirm = viewModel::confirmClear, + onClearDismiss = viewModel::dismissClearDialog, + onCardDismiss = viewModel::dismissCard, + onCardDiscard = viewModel::discardSelectedCard, + onCardConversationClick = viewModel::viewSelectedConversation, + onCardShare = { card -> shareCard(context, card, shareChooserTitle) }, + ) +} + +/** 항상 보이는 부분. 위에 겹쳐 뜨는 시트·다이얼로그는 [ArchiveDetailOverlays] 가 맡는다. */ +@Composable +private fun ArchiveDetailFrame( + emotion: EmotionCharacter, + state: ArchiveDetailState, + onBackClick: () -> Unit, + onPaperClick: (CardEntry) -> Unit, + onMonthClick: () -> Unit, + onClearClick: () -> Unit, +) { + Scaffold( + containerColor = GamssTheme.colors.white, + topBar = { + ArchiveDetailTopBar( + emotion = emotion, + onBackClick = onBackClick, + onClearClick = onClearClick, + ) + }, + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + ArchiveDetailCards(cards = state.cards, onPaperClick = onPaperClick) + // 종이가 쌓일 자리보다 나중에 둔다. 카드가 많아 더미가 위로 넘치면 종이가 셀렉터를 + // 가리고 탭까지 먹어 달을 못 바꾸게 된다. + MonthSelector( + yearMonth = state.yearMonth, + onClick = onMonthClick, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } +} + +/** 셋 다 아무것도 안 뜬 상태가 기본이라, 프레임과 떼어 여기서만 켜고 끈다. */ +@Composable +private fun ArchiveDetailOverlays( + state: ArchiveDetailState, + onMonthSelect: (YearMonth) -> Unit, + onMonthPickerDismiss: () -> Unit, + onClearConfirm: () -> Unit, + onClearDismiss: () -> Unit, + onCardDismiss: () -> Unit, + onCardDiscard: () -> Unit, + onCardConversationClick: () -> Unit, + onCardShare: (Card) -> Unit, +) { + if (state.isMonthPickerVisible) { + YearMonthPickerSheet( + selected = state.yearMonth, + onSelect = onMonthSelect, + onDismiss = onMonthPickerDismiss, + ) + } + + if (state.isClearDialogVisible) { + ClearConfirmDialog(onConfirm = onClearConfirm, onDismiss = onClearDismiss) + } + + state.selectedCard?.let { card -> + CardDetailDialog( + card = card, + onDismiss = onCardDismiss, + onDiscardClick = onCardDiscard, + onViewConversationClick = onCardConversationClick, + onShareClick = { onCardShare(card) }, + ) + } +} + +private fun shareCard(context: Context, card: Card, chooserTitle: String) { + val sendIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "${card.summary}\n\n${card.message}") + } + context.startActivity(Intent.createChooser(sendIntent, chooserTitle)) +} + +/** 되돌릴 수 없는 삭제라 파쇄 화면으로 넘기기 전에 확인을 한 번 받는다. */ +@Composable +private fun ClearConfirmDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + GamssDialog( + title = stringResource(R.string.archive_clear_dialog_title), + subtitle = stringResource(R.string.archive_clear_dialog_description), + primaryAction = GamssDialogAction( + label = stringResource(R.string.archive_clear), + onClick = onConfirm, + variant = GamssButtonVariant.Destructive, + ), + secondaryAction = GamssDialogAction( + label = stringResource(R.string.archive_clear_dialog_cancel), + onClick = onDismiss, + variant = GamssButtonVariant.Secondary, + ), + onDismissRequest = onDismiss, + ) +} + +@Composable +private fun ArchiveDetailTopBar( + emotion: EmotionCharacter, + onBackClick: () -> Unit, + onClearClick: () -> Unit, +) { + GamssTopBar( + // 비우기는 터치 영역만큼 안쪽 여백을 물고 있어, 오른쪽은 그만큼 뺀다. + contentPadding = PaddingValues( + start = TopBarHorizontalPadding, + end = TopBarHorizontalPadding - ClearHitPadding, + ), + leading = { + Row(verticalAlignment = Alignment.CenterVertically) { + GamssIconButton( + iconRes = DesignSystemR.drawable.ic_left_chevron, + contentDescription = stringResource(R.string.archive_back_description), + onClick = onBackClick, + hitPadding = BackHitPadding, + ) + Text( + text = emotion.displayName, + style = GamssTheme.typography.title4, + color = GamssTheme.colors.gray900, + modifier = Modifier.padding(start = TitleStartPadding), + ) + } + }, + trailing = { + Text( + text = stringResource(R.string.archive_clear), + style = GamssTheme.typography.body4Medium, + color = GamssTheme.colors.gray900, + modifier = Modifier + .clip(RoundedCornerShape(ClearRippleRadius)) + .clickable(role = Role.Button, onClick = onClearClick) + .padding(ClearHitPadding), + ) + }, + ) +} + +@Composable +private fun ArchiveDetailCards( + cards: ArchiveCards, + onPaperClick: (CardEntry) -> Unit, +) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + when (cards) { + ArchiveCards.Loading -> CircularProgressIndicator(color = GamssTheme.colors.gray700) + ArchiveCards.LoadFailed -> EmptyMessage(textRes = R.string.archive_cards_load_failed) + is ArchiveCards.Loaded -> if (cards.entries.isEmpty()) { + EmptyMessage(textRes = R.string.archive_cards_empty) + } else { + PaperPile(cards = cards.entries, onPaperClick = onPaperClick) + } + } + } +} + +@Composable +private fun EmptyMessage(@StringRes textRes: Int) { + Text( + text = stringResource(textRes), + style = GamssTheme.typography.body4Medium, + color = GamssTheme.colors.gray500, + ) +} + +private val TopBarHorizontalPadding = 18.dp +private val BackHitPadding = 0.dp +private val TitleStartPadding = 12.dp +private val ClearHitPadding = 8.dp +private val ClearRippleRadius = 8.dp + +@Preview(showBackground = true, widthDp = 402, heightDp = 874) +@Composable +@Suppress("UnusedPrivateMember") +private fun ArchiveDetailPaperPilePreview() { + GamssTheme(darkTheme = false) { + ArchiveDetailFrame( + emotion = EmotionCharacter.QUIRKY, + state = ArchiveDetailState( + emotion = EmotionCharacter.QUIRKY, + yearMonth = YearMonth.of(2026, 7), + cards = ArchiveCards.Loaded(List(24) { index -> PreviewCard.copy(indexInDate = index) }), + ), + onBackClick = {}, + onPaperClick = {}, + onMonthClick = {}, + onClearClick = {}, + ) + } +} + +private val PreviewCard = CardEntry( + date = LocalDate.of(2026, 7, 23), + indexInDate = 0, + character = EmotionCharacter.QUIRKY, +) diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailSideEffect.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailSideEffect.kt new file mode 100644 index 00000000..722b5162 --- /dev/null +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailSideEffect.kt @@ -0,0 +1,10 @@ +package com.gamss.android.feature.archive + +sealed interface ArchiveDetailSideEffect { + data class OpenChatRoom(val conversationId: Long) : ArchiveDetailSideEffect + data object CardLoadFailed : ArchiveDetailSideEffect + data object CardDiscardFailed : ArchiveDetailSideEffect + + /** 실제 삭제는 파쇄 화면이 맡는다. 여기서는 그 화면으로 넘기기만 한다. */ + data object OpenCardDelete : ArchiveDetailSideEffect +} diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailState.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailState.kt new file mode 100644 index 00000000..533fa951 --- /dev/null +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailState.kt @@ -0,0 +1,23 @@ +package com.gamss.android.feature.archive + +import com.gamss.android.domain.card.Card +import com.gamss.android.domain.card.CardEntry +import com.gamss.android.domain.emotion.EmotionCharacter +import java.time.YearMonth + +data class ArchiveDetailState( + val emotion: EmotionCharacter? = null, + val yearMonth: YearMonth, + val cards: ArchiveCards = ArchiveCards.Loading, + val isMonthPickerVisible: Boolean = false, + val isClearDialogVisible: Boolean = false, + val selectedCard: Card? = null, + val isCardLoading: Boolean = false, +) + +/** 종이 더미 자리가 가질 수 있는 상태. 셋이 겹칠 수 없어 플래그 조합 대신 하나로 든다. */ +sealed interface ArchiveCards { + data object Loading : ArchiveCards + data object LoadFailed : ArchiveCards + data class Loaded(val entries: List) : ArchiveCards +} diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt new file mode 100644 index 00000000..476b32fb --- /dev/null +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt @@ -0,0 +1,130 @@ +package com.gamss.android.feature.archive + +import androidx.lifecycle.ViewModel +import com.gamss.android.core.common.AppResult +import com.gamss.android.core.common.util.KoreanTimeZone +import com.gamss.android.domain.card.CardEntry +import com.gamss.android.domain.card.DeleteCardUseCase +import com.gamss.android.domain.card.GetCardsByDateUseCase +import com.gamss.android.domain.card.GetCardsByMonthUseCase +import com.gamss.android.domain.emotion.EmotionCharacter +import dagger.hilt.android.lifecycle.HiltViewModel +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.Syntax +import org.orbitmvi.orbit.viewmodel.container +import java.time.YearMonth +import javax.inject.Inject + +@HiltViewModel +class ArchiveDetailViewModel @Inject constructor( + private val getCardsByMonth: GetCardsByMonthUseCase, + private val getCardsByDate: GetCardsByDateUseCase, + private val deleteCard: DeleteCardUseCase, +) : ViewModel(), ContainerHost { + + override val container = container( + ArchiveDetailState(yearMonth = YearMonth.now(KoreanTimeZone)), + ) + + fun load(emotion: EmotionCharacter) = intent { + if (state.emotion == emotion) return@intent + + reduce { state.copy(emotion = emotion) } + loadMonth(emotion, state.yearMonth) + } + + fun showMonthPicker() = intent { + reduce { state.copy(isMonthPickerVisible = true) } + } + + fun dismissMonthPicker() = intent { + reduce { state.copy(isMonthPickerVisible = false) } + } + + fun showClearDialog() = intent { + reduce { state.copy(isClearDialogVisible = true) } + } + + fun dismissClearDialog() = intent { + reduce { state.copy(isClearDialogVisible = false) } + } + + fun confirmClear() = intent { + reduce { state.copy(isClearDialogVisible = false) } + postSideEffect(ArchiveDetailSideEffect.OpenCardDelete) + } + + fun selectMonth(yearMonth: YearMonth) = intent { + val emotion = state.emotion + // 보고 있는 달을 다시 고르면 이미 쌓인 종이를 다시 쏟지 않고 시트만 닫는다. + if (emotion == null || yearMonth == state.yearMonth) { + reduce { state.copy(isMonthPickerVisible = false) } + return@intent + } + + reduce { + state.copy( + yearMonth = yearMonth, + isMonthPickerVisible = false, + cards = ArchiveCards.Loading, + ) + } + loadMonth(emotion, yearMonth) + } + + /** + * 월별 응답에는 카드 식별자가 없어 종이는 날짜와 그날 순번만 들고 있다. 눌린 종이의 날짜로 다시 + * 조회해 그 순번의 카드를 집어야 요약·대사와 id 가 손에 들어온다. + */ + fun selectCard(entry: CardEntry) = intent { + if (state.isCardLoading) return@intent + + reduce { state.copy(isCardLoading = true) } + val card = when (val result = getCardsByDate(entry.date)) { + is AppResult.Success -> result.data.getOrNull(entry.indexInDate) + is AppResult.Failure -> null + } + reduce { state.copy(isCardLoading = false, selectedCard = card) } + + if (card == null) postSideEffect(ArchiveDetailSideEffect.CardLoadFailed) + } + + fun dismissCard() = intent { + reduce { state.copy(selectedCard = null) } + } + + fun discardSelectedCard() = intent { + val card = state.selectedCard ?: return@intent + val emotion = state.emotion ?: return@intent + reduce { state.copy(selectedCard = null) } + + when (deleteCard(card.id)) { + // 카드를 지우면 같은 날짜 뒤 순번이 한 칸씩 당겨진다. 목록에서 빼는 것으로는 남은 종이의 + // 순번이 어긋나므로 그 달을 다시 받아 온다. + is AppResult.Success -> loadMonth(emotion, state.yearMonth) + is AppResult.Failure -> postSideEffect(ArchiveDetailSideEffect.CardDiscardFailed) + } + } + + fun viewSelectedConversation() = intent { + val conversationId = state.selectedCard?.conversationId ?: return@intent + reduce { state.copy(selectedCard = null) } + postSideEffect(ArchiveDetailSideEffect.OpenChatRoom(conversationId)) + } + + /** 월별 응답은 모든 감정을 섞어 주므로 이 화면이 보고 있는 감정만 남긴다. */ + private suspend fun Syntax.loadMonth( + emotion: EmotionCharacter, + yearMonth: YearMonth, + ) { + val cards = when (val result = getCardsByMonth(yearMonth)) { + is AppResult.Success -> ArchiveCards.Loaded(result.data.filter { it.character == emotion }) + is AppResult.Failure -> ArchiveCards.LoadFailed + } + // intent 는 서로 병렬로 돈다. 느린 달을 기다리는 사이 다른 달로 옮겼다면, 늦게 온 응답이 + // 지금 보고 있는 달의 목록을 덮어써 셀렉터와 종이가 어긋난다. 그래서 덮기 직전에 다시 확인한다. + reduce { + if (state.yearMonth == yearMonth && state.emotion == emotion) state.copy(cards = cards) else state + } + } +} diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveScreen.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveScreen.kt index e332f2be..646ef93d 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveScreen.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveScreen.kt @@ -2,6 +2,8 @@ package com.gamss.android.feature.archive import androidx.annotation.DrawableRes import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -16,10 +18,12 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.gamss.android.core.designsystem.component.GamssIconButton @@ -41,6 +45,7 @@ private val archiveItems = listOf( @Composable fun ArchiveScreen( onNavigateToSetting: () -> Unit, + onArchiveClick: (EmotionCharacter) -> Unit, ) { Scaffold( containerColor = GamssTheme.colors.white, @@ -67,14 +72,17 @@ fun ArchiveScreen( color = GamssTheme.colors.gray950, textAlign = TextAlign.Center, ) - ArchiveGrid(scale = scale) + ArchiveGrid(scale = scale, onArchiveClick = onArchiveClick) } } } } @Composable -private fun ArchiveGrid(scale: Float) { +private fun ArchiveGrid( + scale: Float, + onArchiveClick: (EmotionCharacter) -> Unit, +) { Column( modifier = Modifier.padding( start = GridHorizontalPadding * scale, @@ -95,7 +103,7 @@ private fun ArchiveGrid(scale: Float) { ), ) { rowItems.forEach { item -> - ArchiveCard(item = item, scale = scale) + ArchiveCard(item = item, scale = scale, onClick = { onArchiveClick(item.emotion) }) } } } @@ -121,11 +129,20 @@ private fun ArchiveTopBar(onMenuClick: () -> Unit) { private fun ArchiveCard( item: ArchiveItem, scale: Float, + onClick: () -> Unit, ) { + val interactionSource = remember { MutableInteractionSource() } Image( painter = painterResource(item.binRes), - contentDescription = item.emotion.displayName, - modifier = Modifier.size(width = CardWidth * scale, height = CardHeight * scale), + contentDescription = stringResource(R.string.archive_open_description, item.emotion.displayName), + modifier = Modifier + .size(width = CardWidth * scale, height = CardHeight * scale) + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + onClick = onClick, + ), ) } diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/PaperFall.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/PaperFall.kt new file mode 100644 index 00000000..73fe8791 --- /dev/null +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/PaperFall.kt @@ -0,0 +1,107 @@ +package com.gamss.android.feature.archive + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlin.random.Random + +/** + * 종이 [count] 장을 화면 위쪽에 흩뿌려 두고, [run] 이 도는 동안 [papers] 의 자리를 프레임마다 갱신한다. + * 떨어지고 부딪히는 계산 자체는 [PaperPhysicsWorld] 가 맡는다. + */ +@Stable +internal class PaperFall( + count: Int, + private val boundsWidthPx: Float, + private val boundsHeightPx: Float, + private val radiusPx: Float, +) { + /** 그리는 쪽이 읽어 가는 현재 자리. 프레임마다 바뀐다. */ + val papers: List = spawnPapers(count, boundsWidthPx, radiusPx) + + /** + * 다 쌓여 잠잠해지거나 [PHYSICS_MAX_DURATION_NANOS] 가 지나면 돌아온다 — 화면이 그대로인데도 + * 매 프레임 계속 깨어나지 않게 한다. + */ + suspend fun run() = coroutineScope { + val bodies = papers.map { it.toBody(radiusPx) } + val world = PaperPhysicsWorld(bodies, boundsWidth = boundsWidthPx, boundsHeight = boundsHeightPx) + + var lastFrameNanos = -1L + var startFrameNanos = -1L + var settledFrames = 0 + while (isActive) { + withFrameNanos { frameNanos -> + if (startFrameNanos < 0) startFrameNanos = frameNanos + val dt = frameNanos.deltaSeconds(lastFrameNanos) + lastFrameNanos = frameNanos + + world.step(dt) + bodies.forEachIndexed { index, body -> papers[index].follow(body) } + settledFrames = if (world.maxActivity() < PHYSICS_SETTLE_THRESHOLD) settledFrames + 1 else 0 + } + val settled = settledFrames >= PHYSICS_SETTLE_FRAMES + val timedOut = lastFrameNanos - startFrameNanos > PHYSICS_MAX_DURATION_NANOS + if (settled || timedOut) break + } + } +} + +/** 종이 한 장이 지금 놓인 자리. 프레임마다 바뀌므로 Compose 상태로 든다. */ +@Stable +internal class PaperUiState(x: Float, y: Float, rotationDegrees: Float) { + var x by mutableFloatStateOf(x) + var y by mutableFloatStateOf(y) + var rotationDegrees by mutableFloatStateOf(rotationDegrees) +} + +/** 가로로 흩뿌린 시작 위치. 뒤 순번일수록 더 높이 두어 한꺼번에 떨어지지 않게 한다. */ +private fun spawnPapers(count: Int, boundsWidthPx: Float, radiusPx: Float): List { + val spawnRange = (boundsWidthPx - radiusPx * 2f).coerceAtLeast(0f) + return List(count) { index -> + PaperUiState( + x = radiusPx + Random.nextFloat() * spawnRange, + y = -radiusPx - index * radiusPx * PAPER_SPAWN_STAGGER, + rotationDegrees = (Random.nextFloat() - 0.5f) * 2f * PAPER_MAX_TILT_DEGREES, + ) + } +} + +/** 흩뿌린 자리에서 그대로 출발한다. 낙하가 너무 반듯해 보이지 않게 옆으로 밀고 살짝 돌려 둔다. */ +private fun PaperUiState.toBody(radiusPx: Float) = PaperBody( + startX = x, + startY = y, + startAngle = rotationDegrees.toRadians(), + radius = radiusPx, + startVelX = (Random.nextFloat() - 0.5f) * PAPER_SPAWN_DRIFT, + startAngularVelocity = (Random.nextFloat() - 0.5f) * PAPER_SPAWN_SPIN, +) + +private fun PaperUiState.follow(body: PaperBody) { + x = body.x + y = body.y + rotationDegrees = body.angle.toDegrees() +} + +/** 첫 프레임은 기준이 없어 한 프레임치로 두고, 프레임이 밀렸을 때는 위로 잘라 시뮬레이션이 튀지 않게 한다. */ +private fun Long.deltaSeconds(previousNanos: Long): Float = + if (previousNanos < 0) { + PHYSICS_FIXED_DT + } else { + ((this - previousNanos) / NANOS_PER_SECOND).coerceIn(0f, PHYSICS_MAX_DT) + } + +private const val NANOS_PER_SECOND = 1_000_000_000f +private const val PAPER_MAX_TILT_DEGREES = 42f +private const val PAPER_SPAWN_STAGGER = 0.9f +private const val PAPER_SPAWN_DRIFT = 120f +private const val PAPER_SPAWN_SPIN = 0.15f +private const val PHYSICS_FIXED_DT = 1f / 60f +private const val PHYSICS_MAX_DT = 1f / 30f +private const val PHYSICS_SETTLE_THRESHOLD = 4f +private const val PHYSICS_SETTLE_FRAMES = 30 +private const val PHYSICS_MAX_DURATION_NANOS = 5_000_000_000L diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/PaperPhysics.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/PaperPhysics.kt new file mode 100644 index 00000000..3aa1e820 --- /dev/null +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/PaperPhysics.kt @@ -0,0 +1,190 @@ +package com.gamss.android.feature.archive + +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.sqrt + +internal fun Float.toRadians(): Float = this * (PI.toFloat() / 180f) +internal fun Float.toDegrees(): Float = this * (180f / PI.toFloat()) + +internal class PaperBody( + startX: Float, + startY: Float, + startAngle: Float, + val radius: Float, + startVelX: Float = 0f, + startAngularVelocity: Float = 0f, +) { + var x: Float = startX + var y: Float = startY + var velX: Float = startVelX + var velY: Float = 0f + var angle: Float = startAngle + var angularVelocity: Float = startAngularVelocity +} + +/** + * 원 충돌 근사 기반의 경량 2D 리지드바디 시뮬레이터. 종이가 중력으로 떨어져 바닥·벽·서로에게 부딪혀 + * 쌓이는 연출용이다. + * + * 위치/속도를 불변 Vec2 가 아니라 [PaperBody] 의 Float 필드로 들고 계산도 Float 로 풀어 쓴다 — + * 카드 20여 개면 프레임당 임시 객체가 수천 개씩 생겨 GC 를 압박한다. + */ +internal class PaperPhysicsWorld( + private val bodies: List, + private val boundsWidth: Float, + private val boundsHeight: Float, +) { + fun step(dt: Float) { + bodies.forEach { it.integrate(dt) } + bodies.forEach(::resolveBounds) + // 한 번만 풀면 여러 장이 겹친 자리에서 서로를 밀어내다 만다. 같은 계산을 몇 번 반복해 수렴시킨다. + repeat(COLLISION_ITERATIONS) { resolveAllPairs() } + } + + /** 가장 활발한 종이가 얼마나 움직이는지. 낙하가 끝났는지 판단하는 쪽이 이 값을 본다. */ + fun maxActivity(): Float = bodies.maxOf { body -> + val linear = sqrt(body.velX * body.velX + body.velY * body.velY) + val angular = abs(body.angularVelocity) * ANGULAR_ACTIVITY_WEIGHT + linear + angular + } + + /** 좌우 벽과 바닥에 부딪히면 되튕기고, 스치는 방향으로 살짝 돌려 준다. */ + private fun resolveBounds(body: PaperBody) { + val minX = body.radius + val maxX = boundsWidth - body.radius + val floorY = boundsHeight - body.radius + + when { + body.x < minX -> { + body.x = minX + body.velX = -body.velX * RESTITUTION + body.angularVelocity -= body.velY * WALL_SPIN_TRANSFER + } + + body.x > maxX -> { + body.x = maxX + body.velX = -body.velX * RESTITUTION + body.angularVelocity += body.velY * WALL_SPIN_TRANSFER + } + } + + if (body.y > floorY) { + body.y = floorY + body.velY = if (body.velY > 0f) -body.velY * RESTITUTION else body.velY + val friction = body.velX * FRICTION + body.velX = friction + body.angularVelocity += friction * FLOOR_SPIN_TRANSFER + } + } + + private fun resolveAllPairs() { + for (i in bodies.indices) { + for (j in i + 1 until bodies.size) { + resolvePair(bodies[i], bodies[j]) + } + } + } +} + +/** 중력과 감쇠를 먹여 한 프레임만큼 옮긴다. */ +private fun PaperBody.integrate(dt: Float) { + velY += GRAVITY * dt + x += velX * dt + y += velY * dt + angle += angularVelocity * dt + velX *= LINEAR_DAMPING + velY *= LINEAR_DAMPING + + val damped = (angularVelocity * ANGULAR_DAMPING) + .coerceIn(-MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY) + // 0 에 가까운 회전은 0 으로 끊는다. 안 그러면 쌓인 종이끼리 매 프레임 미세한 토크를 주고받아 + // 제자리에서 계속 돈다. + angularVelocity = if (abs(damped) < ANGULAR_SLEEP_THRESHOLD) 0f else damped +} + +/** 겹친 두 장을 떼어 놓고, 부딪힌 세기만큼 되튕기며 스친 만큼 돌려 준다. */ +private fun resolvePair(a: PaperBody, b: PaperBody) { + val dx = b.x - a.x + val dy = b.y - a.y + val distance = sqrt(dx * dx + dy * dy) + val minDistance = a.radius + b.radius + // 아직 안 닿았거나, 정확히 겹쳐 어느 쪽으로 밀어낼지 정할 수 없는 경우다. + if (distance >= minDistance || distance <= MIN_SEPARATION_DISTANCE) return + + val normalX = dx / distance + val normalY = dy / distance + pushApart(a, b, normalX, normalY, gap = minDistance - distance) + + // 되튕김과 회전은 둘 다 부딪히기 직전의 상대 속도로 계산한다. + val relVelX = b.velX - a.velX + val relVelY = b.velY - a.velY + val approachSpeed = relVelX * normalX + relVelY * normalY + // 겹쳐 있어도 이미 서로 멀어지는 중이면 더 손댈 게 없다. + if (approachSpeed > 0f) return + + applyBounce(a, b, normalX, normalY, approachSpeed) + applySlidingSpin(a, b, tangentSpeed = relVelY * normalX - relVelX * normalY) +} + +/** 겹친 만큼 서로 반씩 밀어낸다. [POSITION_SLOP] 만큼은 남겨 둬야 다음 프레임에 다시 밀리며 떨지 않는다. */ +private fun pushApart(a: PaperBody, b: PaperBody, normalX: Float, normalY: Float, gap: Float) { + val halfOverlap = (gap - POSITION_SLOP).coerceAtLeast(0f) * 0.5f + val correctionX = normalX * halfOverlap + val correctionY = normalY * halfOverlap + a.x -= correctionX + a.y -= correctionY + b.x += correctionX + b.y += correctionY +} + +/** 법선 방향으로 되튕긴다. 종이 무게는 모두 같다고 보고 충격량을 반씩 나눈다. */ +private fun applyBounce( + a: PaperBody, + b: PaperBody, + normalX: Float, + normalY: Float, + approachSpeed: Float, +) { + val magnitude = -(1f + RESTITUTION) * approachSpeed / 2f + val impulseX = normalX * magnitude + val impulseY = normalY * magnitude + a.velX -= impulseX + a.velY -= impulseY + b.velX += impulseX + b.velY += impulseY +} + +/** + * 접선 방향으로 스친 만큼 두 장을 반대로 돌린다. + * + * 다 쌓인 뒤에도 중력으로 매 프레임 서로 닿으며 미세한 접선 속도가 생긴다. 그 이하는 무시한다. + */ +private fun applySlidingSpin(a: PaperBody, b: PaperBody, tangentSpeed: Float) { + if (abs(tangentSpeed) <= RESTING_TANGENT_SPEED) return + + val spinImpulse = tangentSpeed * PAIR_SPIN_TRANSFER + a.angularVelocity -= spinImpulse + b.angularVelocity += spinImpulse +} + +// 아래 상수는 대부분 실기기(갤럭시 S23)로 직접 눈으로 보면서 맞춘 경험적 튜닝값이다. +// 단위는 px/s, px/s², rad/s 등 시뮬레이션 내부 단위 기준이며, 다른 값으로 바꾸면 반드시 +// 실기기에서 낙하 애니메이션을 재확인해야 한다. +private const val GRAVITY = 2600f +private const val LINEAR_DAMPING = 0.995f +private const val ANGULAR_DAMPING = 0.9f +private const val RESTITUTION = 0.32f +private const val FRICTION = 0.9f +private const val COLLISION_ITERATIONS = 4 + +// 회전 전달은 일부러 아주 작게 둔다. 종이는 처음 기울기를 대체로 유지하고 부딪힐 때만 살짝 흔들려야 한다. +private const val PAIR_SPIN_TRANSFER = 0.00012f +private const val WALL_SPIN_TRANSFER = 0.0001f +private const val FLOOR_SPIN_TRANSFER = 0.0002f +private const val MAX_ANGULAR_VELOCITY = 0.8f +private const val MIN_SEPARATION_DISTANCE = 1e-4f +private const val ANGULAR_ACTIVITY_WEIGHT = 40f +private const val ANGULAR_SLEEP_THRESHOLD = 0.05f +private const val RESTING_TANGENT_SPEED = 8f +private const val POSITION_SLOP = 0.5f diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt new file mode 100644 index 00000000..26f9aaed --- /dev/null +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt @@ -0,0 +1,138 @@ +package com.gamss.android.feature.archive.component + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.gamss.android.core.designsystem.card.GamssEmotionCard +import com.gamss.android.core.designsystem.card.GamssEmotionCardCharacter +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.core.ui.card.cardTitleRes +import com.gamss.android.core.ui.card.toGamssEmotionCardCharacter +import com.gamss.android.domain.card.Card +import com.gamss.android.feature.archive.R +import java.time.format.DateTimeFormatter + +/** 배경 dim 은 [Dialog] 창이 기본으로 그려 주므로 여기서 따로 그리지 않는다. */ +@Composable +internal fun CardDetailDialog( + card: Card, + onDismiss: () -> Unit, + onDiscardClick: () -> Unit, + onViewConversationClick: () -> Unit, + onShareClick: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = GamssTheme.spacing.spacing300), + contentAlignment = Alignment.Center, + ) { + GamssEmotionCard( + date = card.date.format(CardDetailDateFormatter), + character = card.character.toGamssEmotionCardCharacter(), + title = stringResource(card.character.cardTitleRes()), + description = card.summary, + primaryActionLabel = stringResource(R.string.archive_card_discard), + secondaryActionLabel = stringResource(R.string.archive_card_view_conversation), + shareActionLabel = stringResource(R.string.archive_card_share), + onPrimaryActionClick = onDiscardClick, + onSecondaryActionClick = onViewConversationClick, + onShareClick = onShareClick, + topEndAction = { CardCloseButton(onClick = onDismiss) }, + ) + } + } +} + +/** + * 카드가 액션 슬롯을 Figma 값(우상단 28dp)에 맞춰 두므로 아이콘은 슬롯 좌상단에 딱 붙어야 한다. + * 그런데 터치 영역을 아이콘보다 크게 잡으면 그 차이만큼 아이콘이 안쪽으로 밀리므로, + * [CloseButtonCenteringInset] 만큼 되돌려 아이콘을 시안 위치로 보낸다. + * + * 터치 영역 크기를 [Box] 로 직접 정한다. `IconButton` 은 기본 크기와 최소 터치 크기 적용이 + * 버전마다 달라 되돌릴 양을 코드에서 확정할 수 없다. + */ +@Composable +private fun CardCloseButton(onClick: () -> Unit) { + Box( + modifier = Modifier + .offset(x = CloseButtonCenteringInset, y = -CloseButtonCenteringInset) + .size(CloseButtonTouchSize) + .clickable(role = Role.Button, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.archive_card_close_description), + tint = GamssTheme.colors.gray300, + modifier = Modifier.size(CloseIconSize), + ) + } +} + +/** Figma 우상단 닫기 아이콘 크기. */ +private val CloseIconSize = 20.dp +private val CloseButtonTouchSize = 48.dp +private val CloseButtonCenteringInset = (CloseButtonTouchSize - CloseIconSize) / 2 + +private val CardDetailDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yy.MM.dd") + +@Preview(name = "Light", showBackground = true) +@Suppress("UnusedPrivateMember") +@Composable +private fun CardDetailDialogLightPreview() { + GamssTheme(darkTheme = false) { + CardDetailDialogPreviewContent() + } +} + +@Preview( + name = "Dark", + showBackground = true, + backgroundColor = 0xFF000000, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Suppress("UnusedPrivateMember") +@Composable +private fun CardDetailDialogDarkPreview() { + GamssTheme(darkTheme = true) { + CardDetailDialogPreviewContent() + } +} + +@Composable +private fun CardDetailDialogPreviewContent() { + GamssEmotionCard( + date = "26.08.03", + character = GamssEmotionCardCharacter.ANGER, + title = "오늘 화~나네", + description = "설느닛람햄을 긱에자네에 신손 겅투히오의 흐랸비의 수매해으는 하어이", + primaryActionLabel = stringResource(R.string.archive_card_discard), + secondaryActionLabel = stringResource(R.string.archive_card_view_conversation), + shareActionLabel = stringResource(R.string.archive_card_share), + onPrimaryActionClick = {}, + onSecondaryActionClick = {}, + onShareClick = {}, + topEndAction = { CardCloseButton(onClick = {}) }, + ) +} diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/MonthSelector.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/MonthSelector.kt new file mode 100644 index 00000000..23a516f6 --- /dev/null +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/MonthSelector.kt @@ -0,0 +1,93 @@ +package com.gamss.android.feature.archive.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.paint +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.feature.archive.R +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import com.gamss.android.core.designsystem.R as DesignSystemR + +@Composable +internal fun MonthSelector( + yearMonth: YearMonth, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .padding( + start = SelectorHorizontalMargin, + end = SelectorHorizontalMargin, + top = SelectorTopMargin, + ) + .fillMaxWidth() + .height(SelectorHeight) + // 입력바와 같은 손그림 테두리 에셋을 재사용한다. + .paint(painterResource(DesignSystemR.drawable.bg_input_box), contentScale = ContentScale.FillBounds) + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = SelectorContentPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = yearMonth.format(YearMonthFormatter), + style = GamssTheme.typography.title4, + color = GamssTheme.colors.gray900, + ) + DownChevron(contentDescription = stringResource(R.string.archive_select_month_description)) + } +} + +/** + * 아래 셰브론 에셋이 없어 오른쪽 셰브론을 돌려 쓴다. + * + * 글리프가 뷰포트 오른쪽에 몰려 있어 박스 중심을 축으로 돌리면 그 가로 편차가 세로 어긋남이 된다. + * 회전축을 글리프 중심에 두고, 대신 박스를 넘어가는 잉크만큼 밀어 좌우 여백을 맞춘다. + */ +@Composable +private fun DownChevron(contentDescription: String?) { + Image( + painter = painterResource(DesignSystemR.drawable.ic_right_chevron), + contentDescription = contentDescription, + modifier = Modifier + .padding(end = ChevronInkOverflow) + .size(ChevronSize) + .graphicsLayer { + rotationZ = CHEVRON_ROTATION + transformOrigin = TransformOrigin(CHEVRON_CENTER_X, CHEVRON_CENTER_Y) + }, + ) +} + +private const val CHEVRON_ROTATION = 90f + +// ic_right_chevron 글리프의 중심. 24 뷰포트에서 stroke 포함 x 12.4~21.3, y 3.9~20.2 다. +private const val CHEVRON_CENTER_X = 16.85f / 24f +private const val CHEVRON_CENTER_Y = 12.03f / 24f + +private val YearMonthFormatter = DateTimeFormatter.ofPattern("yyyy.MM") +private val SelectorHorizontalMargin = 18.dp +private val SelectorTopMargin = 24.dp +private val SelectorHeight = 48.dp +private val SelectorContentPadding = 12.dp +private val ChevronSize = 18.dp +private val ChevronInkOverflow = 1.dp diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/PaperPile.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/PaperPile.kt new file mode 100644 index 00000000..e072ea3e --- /dev/null +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/PaperPile.kt @@ -0,0 +1,82 @@ +package com.gamss.android.feature.archive.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.gamss.android.domain.card.CardEntry +import com.gamss.android.feature.archive.PaperFall +import com.gamss.android.feature.archive.R +import com.gamss.android.feature.archive.designScale +import com.gamss.android.feature.archive.designWidth + +/** 위에서 쏟아져 바닥에 쌓이는 종이 더미. 어디에 어떻게 놓이는지는 [PaperFall] 이 정한다. */ +@Composable +internal fun PaperPile( + cards: List, + onPaperClick: (CardEntry) -> Unit, +) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .padding(top = PileTopPadding), + ) { + val scale = designScale(maxWidth) + val density = LocalDensity.current + val boundsWidthPx = with(density) { designWidth(scale).toPx() } + val boundsHeightPx = with(density) { maxHeight.toPx() } + val paperSizePx = with(density) { (PaperSize * scale).toPx() } + val radiusPx = paperSizePx / 2f * PAPER_COLLISION_RADIUS_SCALE + // 시뮬레이션은 시안 폭(402dp) 안에서만 돈다. 더 넓은 화면에서는 그 폭을 가운데로 밀어야 + // 상단바·월 셀렉터와 축이 맞는다. 종이는 TopStart 기준이라 contentAlignment 로는 안 된다. + val pileOffsetX = with(density) { ((maxWidth - designWidth(scale)) / 2).toPx() } + + // 키에 화면 크기를 넣지 않는다. 회전 등으로 크기만 바뀌었을 때 이미 쌓인 종이가 다시 쏟아진다. + val fall = remember(cards) { + PaperFall(cards.size, boundsWidthPx, boundsHeightPx, radiusPx) + } + + LaunchedEffect(fall) { fall.run() } + + cards.forEachIndexed { index, card -> + val paper = fall.papers.getOrNull(index) ?: return@forEachIndexed + Image( + painter = painterResource(R.drawable.archive_paper), + contentDescription = stringResource( + R.string.archive_paper_description, + card.date.monthValue, + card.date.dayOfMonth, + ), + modifier = Modifier + .align(Alignment.TopStart) + .size(PaperSize * scale) + // 터치 영역도 그려진 자리를 따라가야 하므로 clickable 을 레이어 안쪽에 둔다. + .graphicsLayer { + translationX = pileOffsetX + paper.x - paperSizePx / 2f + translationY = paper.y - paperSizePx / 2f + rotationZ = paper.rotationDegrees + } + .clickable(role = Role.Button) { onPaperClick(card) }, + ) + } + } +} + +/** 종이는 네모지만 충돌은 원으로 근사한다. 모서리까지 덮으려 반지름을 조금 키운다. */ +private const val PAPER_COLLISION_RADIUS_SCALE = 1.15f + +private val PileTopPadding = 136.dp +private val PaperSize = 88.dp diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/YearMonthPickerSheet.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/YearMonthPickerSheet.kt new file mode 100644 index 00000000..786ba694 --- /dev/null +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/YearMonthPickerSheet.kt @@ -0,0 +1,275 @@ +package com.gamss.android.feature.archive.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Arrangement +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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +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.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.gamss.android.core.common.util.KoreanTimeZone +import com.gamss.android.core.designsystem.component.GamssIconButton +import com.gamss.android.core.designsystem.component.GamssIcons +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.feature.archive.R +import java.time.YearMonth +import kotlin.math.abs + +/** + * 휠을 돌리는 동안은 고르는 중이고 `선택하기` 를 눌러야 확정된다. + * + * 월은 12개월을 그대로 준다 — 카드가 있는 달만 남기려면 두 휠이 서로를 잘라내야 해서, 카드가 없는 + * 달은 빈 상태로 답하는 쪽을 택했다. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun YearMonthPickerSheet( + selected: YearMonth, + onSelect: (YearMonth) -> Unit, + onDismiss: () -> Unit, + latest: YearMonth = YearMonth.now(KoreanTimeZone), +) { + val years = remember(latest) { ((latest.year - YEAR_RANGE_SIZE + 1)..latest.year).toList() } + val months = remember { (1..MONTHS_IN_YEAR).toList() } + var pendingYear by remember(selected) { mutableIntStateOf(selected.year) } + var pendingMonth by remember(selected) { mutableIntStateOf(selected.monthValue) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = GamssTheme.colors.white, + shape = RoundedCornerShape(topStart = SheetCornerRadius, topEnd = SheetCornerRadius), + dragHandle = null, + // 디자인 여백은 화면 맨 아래까지 재는 값이라, 시트가 인셋을 예약하면 그만큼 더 벌어진다. + contentWindowInsets = { WindowInsets(0) }, + ) { + Column(modifier = Modifier.padding(bottom = sheetBottomPadding())) { + CloseButton(onClick = onDismiss) + YearMonthWheels( + years = years, + months = months, + selectedYear = pendingYear, + selectedMonth = pendingMonth, + onYearChange = { pendingYear = it }, + onMonthChange = { pendingMonth = it }, + ) + ConfirmButton(onClick = { onSelect(YearMonth.of(pendingYear, pendingMonth)) }) + } + } +} + +/** + * 제스처 바(24dp)는 디자인의 홈 인디케이터처럼 여백 위에 겹쳐도 된다. 3버튼 내비처럼 인셋이 디자인 + * 여백보다 크면 버튼이 내비바에 붙으므로, 그때는 인셋 위로 최소 간격만큼 띄운다. + */ +@Composable +private fun sheetBottomPadding(): Dp { + val systemBarInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + return SheetBottomPadding.coerceAtLeast(systemBarInset + MinGapAboveSystemBar) +} + +@Composable +private fun CloseButton(onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + // hitPadding 만큼 당겨, 아이콘 자체가 디자인 위치에 오게 한다. + .padding(top = SheetTopPadding - HitPadding, end = SheetHorizontalPadding - HitPadding), + horizontalArrangement = Arrangement.End, + ) { + GamssIconButton( + iconRes = GamssIcons.Close, + contentDescription = stringResource(R.string.archive_month_picker_close), + onClick = onClick, + hitPadding = HitPadding, + tint = GamssTheme.colors.gray400, + ) + } +} + +/** 가운데 칸 하이라이트는 두 휠에 걸친 한 덩어리라, 휠 뒤에 한 번만 깔고 가운데 정렬한다. */ +@Composable +private fun YearMonthWheels( + years: List, + months: List, + selectedYear: Int, + selectedMonth: Int, + onYearChange: (Int) -> Unit, + onMonthChange: (Int) -> Unit, +) { + Box( + modifier = Modifier + .padding( + start = SheetHorizontalPadding, + end = SheetHorizontalPadding, + // 위 Row 가 아이콘 아래에도 hitPadding 을 물고 있어 그만큼 뺀다. + top = WheelTopGap - HitPadding, + ) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(WheelRowHeight) + .background(GamssTheme.colors.gray200, RoundedCornerShape(WheelHighlightRadius)), + ) + Row(modifier = Modifier.fillMaxWidth()) { + PickerWheel( + items = years, + selected = selectedYear, + label = { stringResource(R.string.archive_picker_year, it) }, + onSelect = onYearChange, + modifier = Modifier.weight(1f), + ) + PickerWheel( + items = months, + selected = selectedMonth, + label = { stringResource(R.string.archive_picker_month, it) }, + onSelect = onMonthChange, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun ConfirmButton(onClick: () -> Unit) { + Button( + onClick = onClick, + modifier = Modifier + .padding( + start = ConfirmHorizontalPadding, + end = ConfirmHorizontalPadding, + top = ConfirmTopPadding, + ) + .fillMaxWidth() + .height(ConfirmHeight), + shape = RoundedCornerShape(ConfirmCornerRadius), + colors = ButtonDefaults.buttonColors( + containerColor = GamssTheme.colors.gray900, + contentColor = GamssTheme.colors.white, + ), + ) { + Text( + text = stringResource(R.string.archive_month_picker_confirm), + style = GamssTheme.typography.title5, + ) + } +} + +/** + * 가운데 칸에 놓인 값이 고른 값이 되는 휠. 위아래로 [WHEEL_VISIBLE_ROWS] / 2 칸만큼 여백을 줘서 + * 첫 칸과 마지막 칸도 가운데까지 올라올 수 있게 한다. + * + * [selected] 는 처음 어디에 멈춰 있을지만 정한다. 그 뒤로는 휠이 스크롤 위치를 직접 들고 있고, + * 가운데 값이 바뀔 때마다 [onSelect] 로 알린다. + */ +@Composable +private fun PickerWheel( + items: List, + selected: Int, + label: @Composable (Int) -> String, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val selectedIndex = items.indexOf(selected).coerceAtLeast(0) + val listState = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + // 스크롤 도중에도 가운데가 흔들리지 않게, 오프셋을 어림하지 않고 실제로 화면 중앙에 가장 가까운 칸을 찾는다. + // 첫 프레임에는 아직 레이아웃이 없어 가운데를 알 수 없다. 그때 0 번째로 떨어지면 고르지도 않은 첫 칸이 + // 선택돼 버리므로, 레이아웃이 잡히기 전까지는 지금 고른 칸을 그대로 답한다. + val centeredIndex by remember(selectedIndex) { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val viewportCenter = (layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset) / 2 + layoutInfo.visibleItemsInfo + .minByOrNull { abs(it.offset + it.size / 2 - viewportCenter) } + ?.index + ?: selectedIndex + } + } + + LaunchedEffect(items) { + snapshotFlow { centeredIndex }.collect { index -> items.getOrNull(index)?.let(onSelect) } + } + + LazyColumn( + modifier = modifier.height(WheelRowHeight * WHEEL_VISIBLE_ROWS), + state = listState, + contentPadding = PaddingValues(vertical = WheelRowHeight * (WHEEL_VISIBLE_ROWS / 2)), + flingBehavior = rememberSnapFlingBehavior(listState), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + itemsIndexed(items) { index, value -> + Text( + text = label(value), + modifier = Modifier + .height(WheelRowHeight) + .wrapContentHeight(Alignment.CenterVertically), + style = GamssTheme.typography.body3Regular, + color = if (index == centeredIndex) GamssTheme.colors.gray900 else GamssTheme.colors.gray400, + ) + } + } +} + +private const val YEAR_RANGE_SIZE = 5 +private const val MONTHS_IN_YEAR = 12 +private const val WHEEL_VISIBLE_ROWS = 5 + +private val SheetCornerRadius = 20.dp +private val SheetTopPadding = 24.dp +private val SheetBottomPadding = 32.dp +private val MinGapAboveSystemBar = 8.dp +private val SheetHorizontalPadding = 20.dp +private val HitPadding = 12.dp +private val WheelTopGap = 24.dp +private val WheelRowHeight = 40.dp +private val WheelHighlightRadius = 12.dp +private val ConfirmHorizontalPadding = 18.dp +private val ConfirmTopPadding = 24.dp +private val ConfirmHeight = 52.dp +private val ConfirmCornerRadius = 12.dp + +@Preview(showBackground = true, widthDp = 402) +@Composable +@Suppress("UnusedPrivateMember") +private fun YearMonthPickerSheetPreview() { + GamssTheme(darkTheme = false) { + YearMonthPickerSheet( + selected = YearMonth.of(2026, 7), + onSelect = {}, + onDismiss = {}, + latest = YearMonth.of(2026, 8), + ) + } +} diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/navigation/ArchiveDetailKey.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/navigation/ArchiveDetailKey.kt new file mode 100644 index 00000000..1aa70586 --- /dev/null +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/navigation/ArchiveDetailKey.kt @@ -0,0 +1,11 @@ +package com.gamss.android.feature.archive.navigation + +import androidx.navigation3.runtime.NavKey +import com.gamss.android.domain.emotion.EmotionCharacter +import kotlinx.serialization.Serializable + +/** enum을 직접 보존해 호출자가 임의의 문자열을 전달하여 상세 화면이 실패하는 일을 막는다. */ +@Serializable +data class ArchiveDetailKey( + val emotion: EmotionCharacter, +) : NavKey diff --git a/feature/archive/src/main/res/drawable-nodpi/archive_paper.png b/feature/archive/src/main/res/drawable-nodpi/archive_paper.png new file mode 100644 index 00000000..5883028b Binary files /dev/null and b/feature/archive/src/main/res/drawable-nodpi/archive_paper.png differ diff --git a/feature/archive/src/main/res/values/strings.xml b/feature/archive/src/main/res/values/strings.xml index acceb16e..6261555f 100644 --- a/feature/archive/src/main/res/values/strings.xml +++ b/feature/archive/src/main/res/values/strings.xml @@ -3,20 +3,29 @@ 감쓰 다시 보고 싶은 쓰레기통을 열어보세요 설정 - %1$d년 %2$d월 - 이전 달 - 다음 달 - %1$d년 %2$d월 %3$d일 - %1$d년 %2$d월 %3$d일, 오늘 - %1$d월 %2$d일의 카드 - 이 날짜에는 카드가 없어요. - 카드를 불러오지 못했어요. - 다시 시도 - 카드 상세 보기 - 카드 닫기 - 기록 버리기 - 대화보기 - 공유하기 - 카드 공유하기 - 기록을 버리지 못했어요. 다시 시도해주세요 + %1$s 쓰레기통 열기 + + 뒤로 가기 + %1$d월 %2$d일 카드 + 아직 남겨둔 이야기가 없어요. + 이야기를 불러오지 못했어요. + + 비우기 + 대화 전체 비우기 + 모든 대화 기록이 삭제돼요. 삭제한 내용은 다시 복구할 수 없어요. + 취소 + + 월 선택 + 월 선택 닫기 + 선택하기 + %1$d년 + %1$d월 + + 카드 닫기 + 기록 버리기 + 대화보기 + 공유하기 + 카드 공유하기 + 카드를 불러오지 못했어요. + 기록을 버리지 못했어요. 다시 시도해주세요 diff --git a/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt b/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt new file mode 100644 index 00000000..0df13119 --- /dev/null +++ b/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt @@ -0,0 +1,313 @@ +package com.gamss.android.feature.archive + +import com.gamss.android.core.common.AppResult +import com.gamss.android.core.common.util.KoreanTimeZone +import com.gamss.android.domain.card.Card +import com.gamss.android.domain.card.CardEntry +import com.gamss.android.domain.card.CardRepository +import com.gamss.android.domain.card.DeleteCardUseCase +import com.gamss.android.domain.card.GetCardsByDateUseCase +import com.gamss.android.domain.card.GetCardsByMonthUseCase +import com.gamss.android.domain.emotion.EmotionCharacter +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.orbitmvi.orbit.test.test +import java.time.LocalDate +import java.time.YearMonth + +@OptIn(ExperimentalCoroutinesApi::class) +class ArchiveDetailViewModelTest { + + @Test + fun `선택한 감정의 카드만 종이 목록으로 남긴다`() = runTest { + val viewModel = viewModel(FakeCardRepository(AppResult.Success(listOf(angerEntry, joyEntry)))) + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + } + } + + @Test + fun `카드 조회에 실패하면 오류 상태를 표시한다`() = runTest { + val viewModel = viewModel(FakeCardRepository(AppResult.Failure(IllegalStateException("network")))) + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.LoadFailed) } + } + } + + @Test + fun `다른 달을 고르면 그 달을 다시 조회하고 시트를 닫는다`() = runTest { + val repository = FakeCardRepository(AppResult.Success(listOf(angerEntry))) + val viewModel = viewModel(repository) + val previousMonth = YearMonth.of(2026, 7) + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + + containerHost.showMonthPicker() + expectState { copy(isMonthPickerVisible = true) } + + containerHost.selectMonth(previousMonth) + expectState { + copy( + yearMonth = previousMonth, + isMonthPickerVisible = false, + cards = ArchiveCards.Loading, + ) + } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + } + + assertEquals(previousMonth, repository.requestedMonths.last()) + assertEquals(2, repository.requestedMonths.size) + } + + @Test + fun `보고 있는 달을 다시 고르면 다시 조회하지 않고 시트만 닫는다`() = runTest { + val repository = FakeCardRepository(AppResult.Success(listOf(angerEntry))) + val viewModel = viewModel(repository) + val currentMonth = YearMonth.now(KoreanTimeZone) + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + + containerHost.showMonthPicker() + expectState { copy(isMonthPickerVisible = true) } + + containerHost.selectMonth(currentMonth) + expectState { copy(isMonthPickerVisible = false) } + } + + assertEquals(listOf(currentMonth), repository.requestedMonths) + } + + @Test + fun `종이를 누르면 그 날짜의 그날 순번 카드를 상세로 올린다`() = runTest { + val repository = FakeCardRepository( + monthResult = AppResult.Success(listOf(angerEntry, joyEntry)), + dateResult = AppResult.Success(listOf(firstCardOfDay, secondCardOfDay)), + ) + val viewModel = viewModel(repository) + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + + containerHost.selectCard(joyEntry) + expectState { copy(isCardLoading = true) } + expectState { copy(isCardLoading = false, selectedCard = secondCardOfDay) } + } + + assertEquals(listOf(joyEntry.date), repository.requestedDates) + } + + @Test + fun `그 순번에 카드가 없으면 상세를 올리지 않고 실패를 알린다`() = runTest { + val repository = FakeCardRepository( + monthResult = AppResult.Success(listOf(angerEntry)), + dateResult = AppResult.Success(emptyList()), + ) + val viewModel = viewModel(repository) + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + + containerHost.selectCard(angerEntry) + expectState { copy(isCardLoading = true) } + expectState { copy(isCardLoading = false) } + expectSideEffect(ArchiveDetailSideEffect.CardLoadFailed) + } + } + + @Test + fun `카드를 버리면 삭제하고 그 달을 다시 조회한다`() = runTest { + val repository = FakeCardRepository( + monthResult = AppResult.Success(listOf(angerEntry)), + dateResult = AppResult.Success(listOf(firstCardOfDay)), + ) + val viewModel = viewModel(repository) + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + + containerHost.selectCard(angerEntry) + expectState { copy(isCardLoading = true) } + expectState { copy(isCardLoading = false, selectedCard = firstCardOfDay) } + + containerHost.discardSelectedCard() + expectState { copy(selectedCard = null) } + } + + // 재조회 결과가 이전과 같은 상태라 emission 이 더 없다. 다시 받아 왔는지는 호출로 확인한다. + assertEquals(listOf(firstCardOfDay.id), repository.deletedCardIds) + assertEquals(2, repository.requestedMonths.size) + } + + @Test + fun `대화보기를 누르면 그 카드의 대화방을 연다`() = runTest { + val repository = FakeCardRepository( + monthResult = AppResult.Success(listOf(angerEntry)), + dateResult = AppResult.Success(listOf(firstCardOfDay)), + ) + val viewModel = viewModel(repository) + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + + containerHost.selectCard(angerEntry) + expectState { copy(isCardLoading = true) } + expectState { copy(isCardLoading = false, selectedCard = firstCardOfDay) } + + containerHost.viewSelectedConversation() + expectState { copy(selectedCard = null) } + expectSideEffect(ArchiveDetailSideEffect.OpenChatRoom(firstCardOfDay.conversationId)) + } + } + + @Test + fun `늦게 온 이전 달 응답은 지금 보고 있는 달을 덮지 않는다`() = runTest { + val currentMonth = YearMonth.now(KoreanTimeZone) + val slowMonth = currentMonth.minusMonths(2) + val fastMonth = currentMonth.minusMonths(1) + val slowMonthGate = CompletableDeferred() + val repository = FakeCardRepository( + monthResult = AppResult.Success(emptyList()), + monthGates = mapOf(slowMonth to slowMonthGate), + monthResults = mapOf( + slowMonth to AppResult.Success(listOf(staleEntry)), + fastMonth to AppResult.Success(listOf(angerEntry)), + ), + ) + val viewModel = viewModel(repository) + val testScope = this + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(emptyList())) } + + // 응답이 게이트에 걸려 멈춰 있는 동안 다음 달을 고른다. + containerHost.selectMonth(slowMonth) + expectState { copy(yearMonth = slowMonth, cards = ArchiveCards.Loading) } + + containerHost.selectMonth(fastMonth) + expectState { copy(yearMonth = fastMonth, cards = ArchiveCards.Loading) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + + // 뒤늦게 도착한 이전 달 응답이 지금 목록을 덮으면 소비되지 않은 상태가 남는다. + slowMonthGate.complete(Unit) + testScope.runCurrent() + expectNoItems() + } + } + + @Test + fun `비우기를 확인하면 다이얼로그를 닫고 파쇄 화면을 연다`() = runTest { + val viewModel = viewModel(FakeCardRepository(AppResult.Success(listOf(angerEntry)))) + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + + containerHost.showClearDialog() + expectState { copy(isClearDialogVisible = true) } + + // 실제 삭제는 파쇄 화면이 맡으므로 여기서는 카드를 지우지 않는다. + containerHost.confirmClear() + expectState { copy(isClearDialogVisible = false) } + expectSideEffect(ArchiveDetailSideEffect.OpenCardDelete) + } + } + + private fun viewModel(repository: FakeCardRepository) = ArchiveDetailViewModel( + getCardsByMonth = GetCardsByMonthUseCase(repository), + getCardsByDate = GetCardsByDateUseCase(repository), + deleteCard = DeleteCardUseCase(repository), + ) + + private companion object { + val DATE: LocalDate = LocalDate.of(2026, 8, 15) + + val angerEntry = CardEntry(date = DATE, indexInDate = 0, character = EmotionCharacter.ANGER) + val staleEntry = CardEntry(date = DATE.minusMonths(2), indexInDate = 0, character = EmotionCharacter.ANGER) + val joyEntry = CardEntry(date = DATE, indexInDate = 1, character = EmotionCharacter.JOY) + + val firstCardOfDay = card(id = 1L, character = EmotionCharacter.ANGER) + val secondCardOfDay = card(id = 2L, character = EmotionCharacter.JOY) + + fun card(id: Long, character: EmotionCharacter) = Card( + id = id, + conversationId = id * 10, + character = character, + emotionLabel = character.displayName, + summary = "요약 $id", + message = "대사 $id", + date = DATE, + ) + } +} + +private class FakeCardRepository( + private val monthResult: AppResult>, + private val dateResult: AppResult> = AppResult.Success(emptyList()), + /** 여기 담긴 달은 게이트가 열릴 때까지 응답을 붙잡는다. 늦게 도착하는 응답을 만들 때 쓴다. */ + private val monthGates: Map> = emptyMap(), + /** 달마다 다른 목록을 줘야 할 때만 채운다. 없는 달은 [monthResult] 로 답한다. */ + private val monthResults: Map>> = emptyMap(), +) : CardRepository { + + val requestedMonths = mutableListOf() + val requestedDates = mutableListOf() + val deletedCardIds = mutableListOf() + + override suspend fun getCardsByDate(date: LocalDate): AppResult> { + requestedDates += date + return dateResult + } + + override suspend fun getCardsByMonth(yearMonth: YearMonth): AppResult> { + requestedMonths += yearMonth + monthGates[yearMonth]?.await() + return monthResults[yearMonth] ?: monthResult + } + + override suspend fun createCard( + conversationId: Long, + character: EmotionCharacter, + summary: String, + ): AppResult = error("보관함 테스트에서 쓰지 않는다") + + override suspend fun deleteAllCards(): AppResult = + error("보관함 테스트에서 쓰지 않는다") + + override suspend fun deleteCard(cardId: Long): AppResult { + deletedCardIds += cardId + return AppResult.Success(Unit) + } + + override suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult = + error("보관함 테스트에서 쓰지 않는다") +}