diff --git a/app/src/debug/kotlin/com/gamss/android/app/debug/CardDebugActivity.kt b/app/src/debug/kotlin/com/gamss/android/app/debug/CardDebugActivity.kt index 6b8a0f6a..31cafe46 100644 --- a/app/src/debug/kotlin/com/gamss/android/app/debug/CardDebugActivity.kt +++ b/app/src/debug/kotlin/com/gamss/android/app/debug/CardDebugActivity.kt @@ -11,13 +11,19 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.gamss.android.core.common.AppResult @@ -25,8 +31,11 @@ import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.data.remote.conversation.model.response.ConversationMessage import com.gamss.android.data.remote.conversation.model.response.userUtterances import com.gamss.android.domain.card.CardInput +import com.gamss.android.domain.card.CreateCardUseCase import com.gamss.android.domain.card.GenerateCardInputUseCase +import com.gamss.android.domain.card.GetCardsByDateUseCase import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -41,11 +50,17 @@ class CardDebugActivity : ComponentActivity() { @Inject lateinit var generateCardInput: GenerateCardInputUseCase + @Inject + lateinit var createCard: CreateCardUseCase + + @Inject + lateinit var getCardsByDate: GetCardsByDateUseCase + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { GamssTheme { - CardDebugScreen(generateCardInput) + CardDebugScreen(generateCardInput, createCard, getCardsByDate) } } } @@ -57,14 +72,28 @@ private data class DebugResult( val card: AppResult, ) +private data class CreateAndQueryResult( + val creation: AppResult, + val queriedCards: AppResult>?, +) + @Composable -private fun CardDebugScreen(useCase: GenerateCardInputUseCase) { +private fun CardDebugScreen( + generateCardInput: GenerateCardInputUseCase, + createCard: CreateCardUseCase, + getCardsByDate: GetCardsByDateUseCase, +) { val results by produceState?>(initialValue = null) { value = SAMPLES.map { (title, messages) -> val utts = messages.userUtterances() - DebugResult(title, utts, useCase(utts)) + DebugResult(title, utts, generateCardInput(utts)) } } + var conversationIdText by remember { mutableStateOf("") } + var creationResult by remember { mutableStateOf(null) } + var isCreating by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val conversationId = conversationIdText.toLongOrNull() Scaffold { padding -> Column( @@ -81,14 +110,51 @@ private fun CardDebugScreen(useCase: GenerateCardInputUseCase) { CircularProgressIndicator() Text("모델 로드 + 감정/요약 추론 중...") } else { - current.forEach { ResultCard(it) } + OutlinedTextField( + value = conversationIdText, + onValueChange = { conversationIdText = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("종료된 대화방 ID") }, + singleLine = true, + ) + Text("같은 대화방에는 카드가 한 장만 생성됩니다.", style = MaterialTheme.typography.bodySmall) + current.forEach { result -> + ResultCard( + result = result, + canCreate = conversationId != null && !isCreating, + onCreateClick = { cardInput -> + conversationId?.let { id -> + scope.launch { + isCreating = true + val creation = createCard( + CreateCardUseCase.Params( + conversationId = id, + character = cardInput.character, + summary = requireNotNull(cardInput.summary), + ), + ) + val queriedCards = (creation as? AppResult.Success) + ?.data + ?.let { card -> getCardsByDate(card.date) } + creationResult = CreateAndQueryResult(creation, queriedCards) + isCreating = false + } + } + }, + ) + } + creationResult?.let { result -> CreateAndQueryResultCard(result) } } } } } @Composable -private fun ResultCard(result: DebugResult) { +private fun ResultCard( + result: DebugResult, + canCreate: Boolean, + onCreateClick: (CardInput) -> Unit, +) { Card(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(12.dp), @@ -106,6 +172,37 @@ private fun ResultCard(result: DebugResult) { } else { Text("감정: ${input.emotion.koLabel} → 캐릭터: ${input.character.displayName}") Text("요약: ${input.summary ?: "(없음)"}") + Button( + onClick = { onCreateClick(input) }, + enabled = canCreate && !input.summary.isNullOrBlank(), + ) { + Text("테스트 카드 생성") + } + } + } + } + } + } +} + +@Composable +private fun CreateAndQueryResultCard(result: CreateAndQueryResult) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + when (val creation = result.creation) { + is AppResult.Failure -> Text("카드 생성 실패: ${creation.throwable.message}") + is AppResult.Success -> { + Text("카드 생성 성공: #${creation.data.id}") + when (val queried = result.queriedCards) { + null -> Unit + is AppResult.Failure -> Text("날짜별 조회 실패: ${queried.throwable.message}") + is AppResult.Success -> Text( + "날짜별 조회 성공: ${queried.data.size}장 " + + "(생성 카드 포함: ${queried.data.any { it.id == creation.data.id }})", + ) } } } 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 cd40aed1..de1355c7 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 @@ -113,7 +113,11 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { onOpenConversation = { conversationId -> navigator.navigate(ChatRoomKey(conversationId)) }, ) } - entry { CalendarScreen() } + entry { + CalendarScreen( + onOpenConversation = { conversationId -> navigator.navigate(ChatRoomKey(conversationId)) }, + ) + } entry(metadata = detailSlideTransition) { SettingScreen( onBackClick = navigator::goBack, diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssEmotionCardCharacter.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssEmotionCardCharacter.kt new file mode 100644 index 00000000..fe91f6e1 --- /dev/null +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssEmotionCardCharacter.kt @@ -0,0 +1,36 @@ +package com.gamss.android.core.designsystem.card + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import com.gamss.android.core.designsystem.R + +/** 감정 카드에 표시할 캐릭터 6종. 도메인 감정 모델은 화면 계층에서 이 타입으로 변환한다. */ +enum class GamssEmotionCardCharacter( + @get:DrawableRes internal val drawableRes: Int, +) { + JOY(R.drawable.character_happy), + ANGER(R.drawable.character_angry), + ANXIETY(R.drawable.character_anxiety), + SADNESS(R.drawable.character_sad), + QUIRKY(R.drawable.character_wacky), + PRICKLY(R.drawable.character_cranky), +} + +/** 감정 카드 캐릭터를 원본 270×156 비율로 표시한다. */ +@Composable +fun GamssEmotionCardCharacterImage( + character: GamssEmotionCardCharacter, + modifier: Modifier = Modifier, +) { + Image( + painter = painterResource(character.drawableRes), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = modifier.fillMaxSize(), + ) +} diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssImageCard.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssImageCard.kt new file mode 100644 index 00000000..bcb25bd0 --- /dev/null +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssImageCard.kt @@ -0,0 +1,416 @@ +package com.gamss.android.core.designsystem.card + +import android.content.res.Configuration +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.gamss.android.core.designsystem.R +import com.gamss.android.core.designsystem.component.GamssIcons +import com.gamss.android.core.designsystem.component.chat.ChatSender +import com.gamss.android.core.designsystem.component.chat.GamssReceivedChatBubble +import com.gamss.android.core.designsystem.component.chat.GamssSentChatBubble +import com.gamss.android.core.designsystem.modifier.noRippleClickableIfNotNull +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.core.designsystem.theme.LightGamssColors +import com.gamss.android.core.designsystem.theme.LocalGamssColors + +@Composable +fun GamssImageCard( + date: String, + modifier: Modifier = Modifier, + shape: Shape = RectangleShape, + topEndAction: @Composable BoxScope.() -> Unit = {}, + content: @Composable ColumnScope.() -> Unit, +) { + CompositionLocalProvider(LocalGamssColors provides LightGamssColors) { + Box( + modifier = modifier + .widthIn(max = CardWidth) + .fillMaxWidth() + .aspectRatio(CardAspectRatio) + .clip(shape), + ) { + Image( + painter = painterResource(R.drawable.bg_card), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding( + start = GamssTheme.spacing.spacing800, + end = GamssTheme.spacing.spacing800, + top = GamssTheme.spacing.spacing700, + ), + ) { + Text( + text = date, + modifier = Modifier.fillMaxWidth(), + style = GamssTheme.typography.subtitle3, + color = GamssTheme.colors.gray900, + textAlign = TextAlign.Center, + ) + content() + } + + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = GamssTheme.spacing.spacing550, end = GamssTheme.spacing.spacing550), + ) { + topEndAction() + } + } + } +} + +@Preview(name = "Emotion - Light", showBackground = true) +@Suppress("UnusedPrivateMember") +@Composable +private fun GamssImageCardEmotionLightPreview() { + GamssTheme(darkTheme = false) { + Box(modifier = Modifier.padding(GamssTheme.spacing.spacing300)) { + EmotionCardPreviewContent() + } + } +} + +@Preview( + name = "Emotion - Dark", + showBackground = true, + backgroundColor = 0xFF000000, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Suppress("UnusedPrivateMember") +@Composable +private fun GamssImageCardEmotionDarkPreview() { + GamssTheme(darkTheme = true) { + Box(modifier = Modifier.padding(GamssTheme.spacing.spacing300)) { + EmotionCardPreviewContent() + } + } +} + +/** + * 감정 캐릭터와 대화 요약을 보여 주는 이미지 카드 퍼사드. + * + * 카드의 고정 구조와 감정별 캐릭터 선택은 이 컴포넌트가 맡고, 문구와 사용자 동작만 호출부가 제공한다. + * 따라서 화면마다 [GamssImageCard]의 간격과 텍스트 스타일을 다시 조합할 필요가 없다. + */ +@Composable +@Suppress("LongParameterList") +fun GamssEmotionCard( + date: String, + character: GamssEmotionCardCharacter, + title: String, + description: String, + primaryActionLabel: String, + secondaryActionLabel: String, + shareActionLabel: String, + onPrimaryActionClick: () -> Unit, + onSecondaryActionClick: () -> Unit, + onShareClick: () -> Unit, + modifier: Modifier = Modifier, + shape: Shape = RectangleShape, + topEndAction: @Composable BoxScope.() -> Unit = {}, +) { + GamssImageCard( + date = date, + modifier = modifier, + shape = shape, + topEndAction = topEndAction, + ) { + GamssEmotionCardContent(character = character, showDivider = true) { + Text( + text = title, + modifier = Modifier.fillMaxWidth(), + style = GamssTheme.typography.title2, + color = GamssTheme.colors.gray950, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200)) + Text( + text = description, + modifier = Modifier.fillMaxWidth(), + style = GamssTheme.typography.body4Regular, + color = GamssTheme.colors.gray800, + textAlign = TextAlign.Center, + // Figma Description 은 높이 60 / lineHeight 20 으로 3줄까지 담는다. + maxLines = DESCRIPTION_MAX_LINES, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(DividerToContentGap)) + GamssCardDashedDivider() + Spacer(modifier = Modifier.height(DividerToContentGap)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), + ) { + CardOutlinedButton( + text = primaryActionLabel, + onClick = onPrimaryActionClick, + modifier = Modifier.weight(1f), + ) + CardOutlinedButton( + text = secondaryActionLabel, + onClick = onSecondaryActionClick, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200)) + Row( + modifier = Modifier + .fillMaxWidth() + .noRippleClickableIfNotNull(onShareClick), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = shareActionLabel, + style = GamssTheme.typography.body5Medium, + color = GamssTheme.colors.gray600, + ) + Spacer(modifier = Modifier.width(GamssTheme.spacing.spacing025)) + Icon( + painter = painterResource(GamssIcons.RightChevron), + contentDescription = null, + tint = GamssTheme.colors.gray600, + modifier = Modifier.size(GamssTheme.spacing.spacing300), + ) + } + } + } +} + +/** + * 감정 카드의 캐릭터 영역과 그 아래 콘텐츠 간격을 재사용한다. + * + * [showDivider] 를 켜면 캐릭터와 콘텐츠 사이에 점선을 넣는다. 카드 목록처럼 점선이 없는 + * 시안도 같은 캐릭터 영역을 쓰므로 기본값은 꺼짐이다. + */ +@Composable +fun ColumnScope.GamssEmotionCardContent( + character: GamssEmotionCardCharacter, + showDivider: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + Spacer(modifier = Modifier.height(DateToCharacterGap)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(CharacterImageHeight), + ) { + GamssEmotionCardCharacterImage(character = character) + } + if (showDivider) { + Spacer(modifier = Modifier.height(CharacterToDividerGap)) + GamssCardDashedDivider() + Spacer(modifier = Modifier.height(DividerToContentGap)) + } else { + Spacer(modifier = Modifier.height(CharacterToTitleGap)) + } + content() +} + +/** 카드 안을 가로로 끊어 주는 점선. 콘텐츠 열 전체 폭을 쓴다. */ +@Composable +private fun GamssCardDashedDivider(modifier: Modifier = Modifier) { + val color = GamssTheme.colors.gray950 + Canvas( + modifier = modifier + .fillMaxWidth() + .height(CardDividerThickness), + ) { + val y = size.height / 2f + drawLine( + color = color, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = size.height, + pathEffect = PathEffect.dashPathEffect( + floatArrayOf(CardDividerDashLength.toPx(), CardDividerDashGap.toPx()), + ), + ) + } +} + +/** + * 이미지 카드 안에 대화 로그를 배치하는 카드 퍼사드. + * + * 대화 내용 자체는 화면마다 달라 [content] 슬롯으로 남기고, 카드의 상단 여백과 셸만 고정한다. + */ +@Composable +fun GamssChattingCard( + date: String, + modifier: Modifier = Modifier, + shape: Shape = RectangleShape, + topEndAction: @Composable BoxScope.() -> Unit = {}, + content: @Composable ColumnScope.() -> Unit, +) { + GamssImageCard( + date = date, + modifier = modifier, + shape = shape, + topEndAction = topEndAction, + ) { + Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing300)) + content() + } +} + +@Composable +private fun EmotionCardPreviewContent() { + GamssEmotionCard( + date = "26.08.03", + character = GamssEmotionCardCharacter.ANGER, + title = "오늘 화~나네", + description = "설느닛람햄을 긱에자네에 신손 겅투히오의 흐랸비의 수매해으는 하어이", + primaryActionLabel = "기록 버리기", + secondaryActionLabel = "대화보기", + shareActionLabel = "공유하기", + onPrimaryActionClick = {}, + onSecondaryActionClick = {}, + onShareClick = {}, + topEndAction = { CloseIconPlaceholder() }, + ) +} + +/** Figma의 우상단 20dp 닫기 아이콘과 같은 크기·색의 프리뷰 액션. */ +@Composable +private fun CloseIconPlaceholder(modifier: Modifier = Modifier) { + Text( + text = "×", + modifier = modifier.size(GamssTheme.spacing.spacing400), + color = GamssTheme.colors.gray300, + style = GamssTheme.typography.title3.copy(lineHeight = 20.sp), + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun CardOutlinedButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + GamssOutlinedCard( + modifier = modifier, + onClick = onClick, + borderColor = GamssTheme.colors.gray900, + contentPadding = PaddingValues(vertical = GamssTheme.spacing.spacing200), + ) { + Text( + text = text, + modifier = Modifier.fillMaxWidth(), + style = GamssTheme.typography.subtitle4, + color = GamssTheme.colors.gray900, + textAlign = TextAlign.Center, + ) + } +} + +@Preview(name = "Chatting - Light", showBackground = true) +@Suppress("UnusedPrivateMember") +@Composable +private fun GamssImageCardChattingLightPreview() { + GamssTheme(darkTheme = false) { + Box(modifier = Modifier.padding(GamssTheme.spacing.spacing300)) { + ChattingCardPreviewContent() + } + } +} + +@Preview( + name = "Chatting - Dark", + showBackground = true, + backgroundColor = 0xFF000000, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Suppress("UnusedPrivateMember") +@Composable +private fun GamssImageCardChattingDarkPreview() { + GamssTheme(darkTheme = true) { + Box(modifier = Modifier.padding(GamssTheme.spacing.spacing300)) { + ChattingCardPreviewContent() + } + } +} + +@Composable +private fun ChattingCardPreviewContent() { + GamssChattingCard( + date = "26.08.03", + topEndAction = { CloseIconPlaceholder() }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing150)) { + GamssSentChatBubble( + message = "안녕하세요ㅁㅇㄹㅁㅇㄹㅁㅇㄹ", + time = "오후 1:37", + modifier = Modifier.align(Alignment.End), + ) + GamssReceivedChatBubble( + sender = ChatSender(name = "기쁨이"), + message = "안녕! 오늘도 행복한 하루~!", + time = "오후 1:38", + ) + GamssReceivedChatBubble( + sender = ChatSender(name = "슬픔이"), + message = "안녕! 오늘도 행복한 하루~!", + time = "오후 1:38", + ) + } + } +} + +private val CardWidth = 366.dp +private val CardHeight = 528.dp +private val CardAspectRatio = CardWidth.value / CardHeight.value + +private val DateToCharacterGap = 18.dp +private val CharacterImageHeight = 156.dp +private val CharacterToTitleGap = 42.dp + +// 점선 관련 값은 Figma Card(3557:5286)의 Divider 기준이다. 점선은 콘텐츠 열 전체 폭(270)을 +// 쓰고 위아래 22 씩 띄우며, 캐릭터 이미지와는 16 만 띄운다. +private val CharacterToDividerGap = 16.dp +private val DividerToContentGap = 22.dp +private val CardDividerThickness = 1.3.dp +private val CardDividerDashLength = 6.dp +private val CardDividerDashGap = 4.dp +private const val DESCRIPTION_MAX_LINES = 3 diff --git a/core/designsystem/src/main/res/drawable-nodpi/bg_card.png b/core/designsystem/src/main/res/drawable-nodpi/bg_card.png new file mode 100644 index 00000000..035a19b7 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-nodpi/bg_card.png differ diff --git a/core/designsystem/src/main/res/drawable-nodpi/character_angry.png b/core/designsystem/src/main/res/drawable-nodpi/character_angry.png new file mode 100644 index 00000000..ba205712 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-nodpi/character_angry.png differ diff --git a/core/designsystem/src/main/res/drawable-nodpi/character_anxiety.png b/core/designsystem/src/main/res/drawable-nodpi/character_anxiety.png new file mode 100644 index 00000000..d1a39b04 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-nodpi/character_anxiety.png differ diff --git a/core/designsystem/src/main/res/drawable-nodpi/character_cranky.png b/core/designsystem/src/main/res/drawable-nodpi/character_cranky.png new file mode 100644 index 00000000..07fcbd7b Binary files /dev/null and b/core/designsystem/src/main/res/drawable-nodpi/character_cranky.png differ diff --git a/core/designsystem/src/main/res/drawable-nodpi/character_happy.png b/core/designsystem/src/main/res/drawable-nodpi/character_happy.png new file mode 100644 index 00000000..7e446539 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-nodpi/character_happy.png differ diff --git a/core/designsystem/src/main/res/drawable-nodpi/character_sad.png b/core/designsystem/src/main/res/drawable-nodpi/character_sad.png new file mode 100644 index 00000000..6c7710eb Binary files /dev/null and b/core/designsystem/src/main/res/drawable-nodpi/character_sad.png differ diff --git a/core/designsystem/src/main/res/drawable-nodpi/character_wacky.png b/core/designsystem/src/main/res/drawable-nodpi/character_wacky.png new file mode 100644 index 00000000..56c2ae05 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-nodpi/character_wacky.png differ diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 4448d9ff..310d765e 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -8,6 +8,9 @@ android { } dependencies { + implementation(projects.core.designsystem) + implementation(projects.domain) + implementation(libs.androidx.core.ktx) implementation(libs.compose.material.icons.core) diff --git a/core/ui/src/main/java/com/gamss/android/core/ui/card/EmotionCharacterCardMapping.kt b/core/ui/src/main/java/com/gamss/android/core/ui/card/EmotionCharacterCardMapping.kt new file mode 100644 index 00000000..d20b1e2b --- /dev/null +++ b/core/ui/src/main/java/com/gamss/android/core/ui/card/EmotionCharacterCardMapping.kt @@ -0,0 +1,32 @@ +package com.gamss.android.core.ui.card + +import androidx.annotation.StringRes +import com.gamss.android.core.designsystem.card.GamssEmotionCardCharacter +import com.gamss.android.core.ui.R +import com.gamss.android.domain.emotion.EmotionCharacter + +/** 도메인 캐릭터를 감정 카드의 디자인 시스템 캐릭터로 변환한다. */ +fun EmotionCharacter.toGamssEmotionCardCharacter(): GamssEmotionCardCharacter = when (this) { + EmotionCharacter.JOY -> GamssEmotionCardCharacter.JOY + EmotionCharacter.ANGER -> GamssEmotionCardCharacter.ANGER + EmotionCharacter.ANXIETY -> GamssEmotionCardCharacter.ANXIETY + EmotionCharacter.SADNESS -> GamssEmotionCardCharacter.SADNESS + EmotionCharacter.QUIRKY -> GamssEmotionCardCharacter.QUIRKY + EmotionCharacter.PRICKLY -> GamssEmotionCardCharacter.PRICKLY +} + +/** + * 감정 카드 제목. + * + * 제목은 서버가 준 요약·대사가 아니라 카드 캐릭터로 정해지는 고정 문구다. 요약은 제목 아래 + * 작은 텍스트로 따로 들어간다. + */ +@StringRes +fun EmotionCharacter.cardTitleRes(): Int = when (this) { + EmotionCharacter.JOY -> R.string.emotion_card_title_joy + EmotionCharacter.ANGER -> R.string.emotion_card_title_anger + EmotionCharacter.ANXIETY -> R.string.emotion_card_title_anxiety + EmotionCharacter.SADNESS -> R.string.emotion_card_title_sadness + EmotionCharacter.QUIRKY -> R.string.emotion_card_title_quirky + EmotionCharacter.PRICKLY -> R.string.emotion_card_title_prickly +} diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml new file mode 100644 index 00000000..268ecb83 --- /dev/null +++ b/core/ui/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + + 오늘 기~쁘네 + 오늘 화~나네 + 오늘 불~안하네 + 오늘 슬~프네 + 오늘 엉~뚱하네 + 오늘 까~칠하네 + diff --git a/data/src/main/java/com/gamss/android/data/remote/card/CardService.kt b/data/src/main/java/com/gamss/android/data/remote/card/CardService.kt index 0d5ae9fb..6ea5c4cc 100644 --- a/data/src/main/java/com/gamss/android/data/remote/card/CardService.kt +++ b/data/src/main/java/com/gamss/android/data/remote/card/CardService.kt @@ -4,11 +4,22 @@ import com.gamss.android.data.remote.card.model.request.CreateCardRequest import com.gamss.android.data.remote.card.model.response.CardResponse import com.gamss.android.data.remote.model.response.ApiResponse import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query internal interface CardService { + @GET("/api/cards") + suspend fun getCardsByDate(@Query("date") date: String): ApiResponse> + /** 종료된 채팅방에만 만들 수 있다. 대사는 서버가 생성한다. */ @POST("/api/cards") suspend fun createCard(@Body request: CreateCardRequest): ApiResponse + + /** 카드 한 장과 카드가 나온 채팅방을 함께 삭제한다. envelope 의 success 만 보고 data 는 쓰지 않는다. */ + @DELETE("/api/cards/{cardId}") + suspend fun deleteCard(@Path("cardId") cardId: Long): ApiResponse } diff --git a/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt b/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt index 9ebf3e2d..f2e8e00f 100644 --- a/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt +++ b/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt @@ -1,21 +1,58 @@ package com.gamss.android.data.remote.card.model.response +import com.gamss.android.data.remote.emotion.toEmotionCharacter import com.gamss.android.domain.card.Card import com.gamss.android.domain.emotion.EmotionCharacter import kotlinx.serialization.Serializable +import java.time.LocalDate +import java.time.format.DateTimeParseException @Serializable internal data class CardResponse( + val id: Long, + val conversationId: Long, + val emotion: String, + val emotionLabel: String, val summary: String, val message: String, + val date: String, ) /** - * 서버는 우리가 보낸 감정을 그대로 대표 감정으로 쓰므로 응답 문자열을 다시 해석하지 않고 - * 보낸 캐릭터를 그대로 쓴다. 해석 실패로 카드를 잃는 경로를 없앤다. + * 생성 응답을 변환한다. POST 는 이미 성공한 뒤라 여기서 실패로 돌리면 서버엔 카드가 있는데 + * 클라만 실패로 보고, 재시도는 CARD_ALREADY_EXISTS 로 막혀 그 카드에 영영 닿지 못한다. + * 그래서 매핑하지 못한 값은 요청에 쓴 값으로 메운다. */ -internal fun CardResponse.toDomain(character: EmotionCharacter): Card = Card( - character = character, +internal fun CardResponse.toDomain( + requestedCharacter: EmotionCharacter, + fallbackDate: LocalDate, +): Card = Card( + id = id, + conversationId = conversationId, + character = emotion.toEmotionCharacter() ?: requestedCharacter, + emotionLabel = emotionLabel, summary = summary, message = message, + date = date.toLocalDateOrNull() ?: fallbackDate, ) + +/** 목록 응답의 예상 가능한 서버 데이터 오류가 정상 카드까지 가리지 않도록 안전하게 변환한다. */ +internal fun CardResponse.toDomainOrNull(): Card? = emotion.toEmotionCharacter()?.let { character -> + date.toLocalDateOrNull()?.let { parsedDate -> + Card( + id = id, + conversationId = conversationId, + character = character, + emotionLabel = emotionLabel, + summary = summary, + message = message, + date = parsedDate, + ) + } +} + +private fun String.toLocalDateOrNull(): LocalDate? = try { + LocalDate.parse(this) +} catch (_: DateTimeParseException) { + null +} diff --git a/data/src/main/java/com/gamss/android/data/repository/ApiErrorCodes.kt b/data/src/main/java/com/gamss/android/data/repository/ApiErrorCodes.kt index 0cb4c28e..9d76a739 100644 --- a/data/src/main/java/com/gamss/android/data/repository/ApiErrorCodes.kt +++ b/data/src/main/java/com/gamss/android/data/repository/ApiErrorCodes.kt @@ -12,3 +12,4 @@ internal fun Throwable.hasErrorCode(code: String): Boolean = internal const val CONVERSATION_ALREADY_ENDED = "CONVERSATION_ALREADY_ENDED" internal const val CONVERSATION_ALREADY_DELETED = "CONVERSATION_ALREADY_DELETED" internal const val CARD_ALREADY_EXISTS = "CARD_ALREADY_EXISTS" +internal const val CARD_ALREADY_DELETED = "CARD_ALREADY_DELETED" diff --git a/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt index 18cb79ca..b2f19652 100644 --- a/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt @@ -4,12 +4,15 @@ import com.gamss.android.core.common.AppResult import com.gamss.android.data.remote.card.CardService import com.gamss.android.data.remote.card.model.request.CreateCardRequest import com.gamss.android.data.remote.card.model.response.toDomain +import com.gamss.android.data.remote.card.model.response.toDomainOrNull import com.gamss.android.data.remote.emotion.toServerEmotionType import com.gamss.android.data.remote.runCatchingApiCall +import com.gamss.android.data.remote.throwIfFailed import com.gamss.android.domain.card.Card import com.gamss.android.domain.card.CardNotRetryableException import com.gamss.android.domain.card.CardRepository import com.gamss.android.domain.emotion.EmotionCharacter +import java.time.LocalDate import javax.inject.Inject import javax.inject.Singleton @@ -18,6 +21,12 @@ internal class CardRepositoryImpl @Inject constructor( private val cardService: CardService, ) : CardRepository { + override suspend fun getCardsByDate(date: LocalDate): AppResult> = runCatchingApiCall { + val response = cardService.getCardsByDate(date.toString()) + response.throwIfFailed() + checkNotNull(response.data) { "No available card data" }.mapNotNull { it.toDomainOrNull() } + } + override suspend fun createCard( conversationId: Long, character: EmotionCharacter, @@ -31,7 +40,9 @@ internal class CardRepositoryImpl @Inject constructor( summary = summary, ), ) - checkNotNull(response.data) { "No available card data" }.toDomain(character) + response.throwIfFailed() + checkNotNull(response.data) { "No available card data" } + .toDomain(requestedCharacter = character, fallbackDate = LocalDate.now()) } return when (result) { is AppResult.Success -> result @@ -44,4 +55,20 @@ internal class CardRepositoryImpl @Inject constructor( } } } + + override suspend fun deleteCard(cardId: Long): AppResult { + val result = runCatchingApiCall { + cardService.deleteCard(cardId).throwIfFailed() + } + return when (result) { + is AppResult.Success -> AppResult.Success(Unit) + // 이미 지워진 카드면 목표는 달성된 상태다. 실패로 흘리면 재시도가 영원히 같은 오류를 받는다. + is AppResult.Failure -> + if (result.throwable.hasErrorCode(CARD_ALREADY_DELETED)) { + AppResult.Success(Unit) + } else { + result + } + } + } } diff --git a/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt b/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt new file mode 100644 index 00000000..ac951fbd --- /dev/null +++ b/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt @@ -0,0 +1,50 @@ +package com.gamss.android.data.repository + +import com.gamss.android.core.common.AppResult +import com.gamss.android.core.common.network.ApiException +import com.gamss.android.data.remote.card.CardService +import com.gamss.android.data.remote.model.response.ApiError +import com.gamss.android.data.remote.model.response.ApiResponse +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class CardRepositoryImplTest { + + private val cardService: CardService = mockk() + private val repository = CardRepositoryImpl(cardService) + + @Test + fun `카드 삭제 요청을 전달한다`() = runTest { + coEvery { cardService.deleteCard(1L) } returns ApiResponse(success = true, data = Unit) + + assertEquals(AppResult.Success(Unit), repository.deleteCard(1L)) + } + + @Test + fun `이미 삭제된 카드는 성공으로 전달한다`() = runTest { + coEvery { cardService.deleteCard(1L) } returns ApiResponse( + success = false, + error = ApiError(code = "CARD_ALREADY_DELETED", message = "이미 삭제됨"), + ) + + assertEquals(AppResult.Success(Unit), repository.deleteCard(1L)) + } + + @Test + fun `카드 삭제 실패 envelope는 실패로 전달한다`() = runTest { + coEvery { cardService.deleteCard(1L) } returns ApiResponse( + success = false, + error = ApiError(code = "CARD_NOT_FOUND", message = "존재하지 않음"), + ) + + val result = repository.deleteCard(1L) + + assertEquals( + "CARD_NOT_FOUND", + ((result as AppResult.Failure).throwable as ApiException).code, + ) + } +} diff --git a/data/src/test/kotlin/com/gamss/android/data/remote/card/CardResponseTest.kt b/data/src/test/kotlin/com/gamss/android/data/remote/card/CardResponseTest.kt new file mode 100644 index 00000000..f82befa9 --- /dev/null +++ b/data/src/test/kotlin/com/gamss/android/data/remote/card/CardResponseTest.kt @@ -0,0 +1,86 @@ +package com.gamss.android.data.remote.card + +import com.gamss.android.data.remote.card.model.response.CardResponse +import com.gamss.android.data.remote.card.model.response.toDomain +import com.gamss.android.data.remote.card.model.response.toDomainOrNull +import com.gamss.android.domain.emotion.EmotionCharacter +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate + +class CardResponseTest { + + @Test + fun `card response preserves the card fields returned by the server`() { + val card = CardResponse( + id = 10L, + conversationId = 20L, + emotion = "ANGER", + emotionLabel = "분노", + summary = "비 때문에 하루가 꼬였어요", + message = "비 때문에 하루가 꼬였어요", + date = "2026-08-15", + ).toDomain(requestedCharacter = EmotionCharacter.ANGER, fallbackDate = LocalDate.of(2026, 1, 1)) + + assertEquals(10L, card.id) + assertEquals(20L, card.conversationId) + assertEquals(EmotionCharacter.ANGER, card.character) + assertEquals("분노", card.emotionLabel) + assertEquals("비 때문에 하루가 꼬였어요", card.summary) + assertEquals(LocalDate.of(2026, 8, 15), card.date) + } + + @Test + fun `card creation falls back to the requested values when the response cannot be mapped`() { + val fallbackDate = LocalDate.of(2026, 8, 17) + + val card = CardResponse( + id = 10L, + conversationId = 20L, + emotion = "FUTURE_EMOTION", + emotionLabel = "미래 감정", + summary = "요약", + message = "메시지", + date = "invalid-date", + ).toDomain(requestedCharacter = EmotionCharacter.ANGER, fallbackDate = fallbackDate) + + assertEquals(10L, card.id) + assertEquals(EmotionCharacter.ANGER, card.character) + assertEquals(fallbackDate, card.date) + } + + @Test + fun `invalid card responses do not hide valid cards in a list`() { + val cards = listOf( + CardResponse( + id = 10L, + conversationId = 20L, + emotion = "ANGER", + emotionLabel = "분노", + summary = "요약", + message = "메시지", + date = "2026-08-15", + ), + CardResponse( + id = 11L, + conversationId = 20L, + emotion = "FUTURE_EMOTION", + emotionLabel = "미래 감정", + summary = "요약", + message = "메시지", + date = "2026-08-15", + ), + CardResponse( + id = 12L, + conversationId = 20L, + emotion = "ANGER", + emotionLabel = "분노", + summary = "요약", + message = "메시지", + date = "invalid-date", + ), + ).mapNotNull { it.toDomainOrNull() } + + assertEquals(listOf(10L), cards.map { it.id }) + } +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/Card.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/Card.kt index 5649106e..ef9c12eb 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/card/Card.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/card/Card.kt @@ -1,6 +1,7 @@ package com.gamss.android.domain.card import com.gamss.android.domain.emotion.EmotionCharacter +import java.time.LocalDate /** * 대화 종료로 만들어진 감정 카드. @@ -8,9 +9,13 @@ import com.gamss.android.domain.emotion.EmotionCharacter * @param summary 클라이언트가 보낸 압축본. 카드 제목으로 그대로 노출된다. */ data class Card( + val id: Long, + val conversationId: Long, val character: EmotionCharacter, + val emotionLabel: String, val summary: String, val message: String, + val date: LocalDate, ) /** 다시 시도해도 결과가 같은 카드 실패. 호출부가 재시도 경로를 접는 기준이다. */ diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt index a4ff76ea..494f0f7b 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt @@ -2,13 +2,20 @@ package com.gamss.android.domain.card import com.gamss.android.core.common.AppResult import com.gamss.android.domain.emotion.EmotionCharacter +import java.time.LocalDate interface CardRepository { + /** 대화 생성일(KST) 기준으로 해당 날짜의 카드를 가져온다. */ + suspend fun getCardsByDate(date: LocalDate): AppResult> + /** 종료된 채팅방에만 만들 수 있고 방당 한 번만 성공한다. */ suspend fun createCard( conversationId: Long, character: EmotionCharacter, summary: String, ): AppResult + + /** 카드 한 장과 카드가 나온 채팅방을 함께 삭제한다. 되돌릴 수 없다. */ + suspend fun deleteCard(cardId: Long): AppResult } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/DeleteCardUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/DeleteCardUseCase.kt new file mode 100644 index 00000000..287ea140 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/card/DeleteCardUseCase.kt @@ -0,0 +1,13 @@ +package com.gamss.android.domain.card + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.usecase.UseCase +import javax.inject.Inject + +/** 카드 한 장을 삭제한다. */ +class DeleteCardUseCase @Inject constructor( + private val cardRepository: CardRepository, +) : UseCase> { + + override suspend fun invoke(params: Long): AppResult = cardRepository.deleteCard(params) +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByDateUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByDateUseCase.kt new file mode 100644 index 00000000..fbf93218 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByDateUseCase.kt @@ -0,0 +1,15 @@ +package com.gamss.android.domain.card + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.usecase.UseCase +import java.time.LocalDate +import javax.inject.Inject + +/** 선택한 날짜(KST)의 카드 목록을 조회한다. */ +class GetCardsByDateUseCase @Inject constructor( + private val cardRepository: CardRepository, +) : UseCase>> { + + override suspend fun invoke(params: LocalDate): AppResult> = + cardRepository.getCardsByDate(params) +} diff --git a/domain/src/test/kotlin/com/gamss/android/domain/card/CreateCardUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/card/CreateCardUseCaseTest.kt index 9abef4b3..55f55eda 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/card/CreateCardUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/card/CreateCardUseCaseTest.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.time.LocalDate class CreateCardUseCaseTest { @@ -19,8 +20,23 @@ class CreateCardUseCaseTest { summary: String, ): AppResult { sentSummary = summary - return AppResult.Success(Card(character = character, summary = summary, message = "대사")) + return AppResult.Success( + Card( + id = 1L, + conversationId = conversationId, + character = character, + emotionLabel = character.displayName, + summary = summary, + message = "대사", + date = LocalDate.of(2026, 8, 15), + ), + ) } + + override suspend fun getCardsByDate(date: LocalDate): AppResult> = + AppResult.Success(emptyList()) + + override suspend fun deleteCard(cardId: Long): AppResult = error("사용하지 않음") } @Test diff --git a/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardUseCaseTest.kt new file mode 100644 index 00000000..285749cb --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardUseCaseTest.kt @@ -0,0 +1,39 @@ +package com.gamss.android.domain.card + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.emotion.EmotionCharacter +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate + +class DeleteCardUseCaseTest { + + private class RecordingRepository : CardRepository { + var deletedCardId: Long? = null + private set + + override suspend fun getCardsByDate(date: LocalDate): AppResult> = error("사용하지 않음") + + override suspend fun createCard( + conversationId: Long, + character: EmotionCharacter, + summary: String, + ): AppResult = error("사용하지 않음") + + override suspend fun deleteCard(cardId: Long): AppResult { + deletedCardId = cardId + return AppResult.Success(Unit) + } + } + + @Test + fun `카드 삭제를 리포지토리에 위임한다`() = runBlocking { + val repository = RecordingRepository() + + val result = DeleteCardUseCase(repository)(1L) + + assertEquals(1L, repository.deletedCardId) + assertEquals(AppResult.Success(Unit), result) + } +} diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt index 884a4fa0..35e3ce79 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.time.LocalDate class ConversationSessionTest { @@ -288,11 +289,26 @@ class ConversationSessionTest { } private object NoOpCardRepository : CardRepository { + override suspend fun getCardsByDate(date: LocalDate): AppResult> = + AppResult.Success(emptyList()) + override suspend fun createCard( conversationId: Long, character: EmotionCharacter, summary: String, - ): AppResult = AppResult.Success(Card(character = character, summary = summary, message = "대사")) + ): AppResult = AppResult.Success( + Card( + id = 1L, + conversationId = conversationId, + character = character, + emotionLabel = character.displayName, + summary = summary, + message = "대사", + date = LocalDate.of(2026, 8, 15), + ), + ) + + override suspend fun deleteCard(cardId: Long): AppResult = error("사용하지 않음") } private class FakeConversationRepository( diff --git a/feature/calendar/build.gradle.kts b/feature/calendar/build.gradle.kts index 3c16e441..ac515dd2 100644 --- a/feature/calendar/build.gradle.kts +++ b/feature/calendar/build.gradle.kts @@ -8,6 +8,7 @@ android { dependencies { implementation(projects.core.ui) + implementation(projects.domain) implementation(libs.calendar.compose) { exclude(group = "androidx.compose.ui", module = "ui-tooling") diff --git a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarScreen.kt b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarScreen.kt index 9ad0eaa8..c54664bc 100644 --- a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarScreen.kt +++ b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarScreen.kt @@ -1,28 +1,68 @@ package com.gamss.android.feature.calendar +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.gamss.android.core.designsystem.card.GamssEmotionCardContent +import com.gamss.android.core.designsystem.card.GamssImageCard +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.core.ui.card.toGamssEmotionCardCharacter +import com.gamss.android.domain.card.Card import com.gamss.android.feature.calendar.component.CalendarDayCell +import com.gamss.android.feature.calendar.component.CardDetailDialog import com.gamss.android.feature.calendar.component.MonthCalendar import com.kizitonwose.calendar.core.DayPosition import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect +import java.time.LocalDate import java.time.YearMonth +import java.time.format.DateTimeFormatter private const val PAST_MONTH_COUNT = 60L private const val FUTURE_MONTH_COUNT = 12L @Composable fun CalendarScreen( + onOpenConversation: (Long) -> Unit, viewModel: CalendarViewModel = hiltViewModel(), ) { val state by viewModel.collectAsState() + val context = LocalContext.current + val shareChooserTitle = stringResource(R.string.calendar_card_share_chooser_title) + val discardFailureMessage = stringResource(R.string.calendar_card_discard_failure) + + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is CalendarSideEffect.OpenChatRoom -> onOpenConversation(sideEffect.conversationId) + CalendarSideEffect.CardDiscardFailed -> + Toast.makeText(context, discardFailureMessage, Toast.LENGTH_SHORT).show() + } + } val anchorMonth = remember(state.today) { YearMonth.from(state.today) } val monthRange = remember(anchorMonth) { @@ -30,21 +70,143 @@ fun CalendarScreen( } Scaffold { innerPadding -> - MonthCalendar( - monthRange = monthRange, - initialMonth = anchorMonth, + Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) .padding(horizontal = 16.dp), - ) { day -> - val isInCurrentMonth = day.position == DayPosition.MonthDate - CalendarDayCell( - day = day, - isSelected = isInCurrentMonth && day.date == state.selectedDate, - isToday = isInCurrentMonth && day.date == state.today, - onClick = viewModel::selectDate, + ) { + MonthCalendar( + monthRange = monthRange, + initialMonth = anchorMonth, + ) { day -> + val isInCurrentMonth = day.position == DayPosition.MonthDate + CalendarDayCell( + day = day, + isSelected = isInCurrentMonth && day.date == state.selectedDate, + isToday = isInCurrentMonth && day.date == state.today, + onClick = viewModel::selectDate, + ) + } + CalendarCardResults( + selectedDate = state.selectedDate, + loadState = state.cardLoadState, + onRetryClick = viewModel::retrySelectedDate, + onCardClick = viewModel::selectCard, + modifier = Modifier.weight(1f), ) } } + + state.selectedCard?.let { card -> + CardDetailDialog( + card = card, + onDismiss = viewModel::dismissCardDetail, + onDiscardClick = viewModel::discardSelectedCard, + onViewConversationClick = viewModel::viewSelectedConversation, + onShareClick = { shareCard(context, card, shareChooserTitle) }, + ) + } +} + +private fun shareCard(context: Context, card: Card, chooserTitle: String) { + val shareText = "${card.summary}\n\n${card.message}" + val sendIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareText) + } + context.startActivity(Intent.createChooser(sendIntent, chooserTitle)) } + +@Composable +private fun CalendarCardResults( + selectedDate: LocalDate?, + loadState: CalendarCardLoadState, + onRetryClick: () -> Unit, + onCardClick: (Card) -> Unit, + modifier: Modifier = Modifier, +) { + if (selectedDate == null) return + + Column(modifier = modifier.padding(top = 24.dp)) { + Text( + text = stringResource( + R.string.calendar_card_result_title, + selectedDate.monthValue, + selectedDate.dayOfMonth, + ), + style = GamssTheme.typography.title3, + color = GamssTheme.colors.gray950, + ) + Spacer(modifier = Modifier.height(12.dp)) + when (loadState) { + CalendarCardLoadState.Idle -> Unit + CalendarCardLoadState.Loading -> CircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + CalendarCardLoadState.Empty -> Text( + text = stringResource(R.string.calendar_card_empty), + style = GamssTheme.typography.body4Regular, + color = GamssTheme.colors.gray600, + ) + CalendarCardLoadState.Error -> { + Text( + text = stringResource(R.string.calendar_card_load_error), + style = GamssTheme.typography.body4Regular, + color = GamssTheme.colors.gray600, + ) + TextButton(onClick = onRetryClick) { + Text(text = stringResource(R.string.calendar_card_retry)) + } + } + is CalendarCardLoadState.Content -> LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(loadState.cards, key = Card::id) { card -> + CalendarCardItem(card, onClick = { onCardClick(card) }) + } + } + } + } +} + +@Composable +private fun CalendarCardItem(card: Card, onClick: () -> Unit) { + val detailDescription = stringResource(R.string.calendar_card_detail_description) + Box( + modifier = Modifier + .fillMaxWidth() + .clickable(onClickLabel = detailDescription, onClick = onClick), + contentAlignment = Alignment.TopCenter, + ) { + GamssImageCard(date = card.date.format(CardDateFormatter)) { + GamssEmotionCardContent( + character = card.character.toGamssEmotionCardCharacter(), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = card.emotionLabel, + modifier = Modifier.fillMaxWidth(), + style = GamssTheme.typography.subtitle3, + color = GamssTheme.colors.gray800, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + Text( + text = card.summary, + modifier = Modifier.fillMaxWidth(), + style = GamssTheme.typography.title3, + color = GamssTheme.colors.gray950, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +private val CardDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yy.MM.dd") diff --git a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarSideEffect.kt b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarSideEffect.kt index 104161b1..742ba057 100644 --- a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarSideEffect.kt +++ b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarSideEffect.kt @@ -1,3 +1,6 @@ package com.gamss.android.feature.calendar -sealed interface CalendarSideEffect +sealed interface CalendarSideEffect { + data class OpenChatRoom(val conversationId: Long) : CalendarSideEffect + data object CardDiscardFailed : CalendarSideEffect +} diff --git a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarState.kt b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarState.kt index b266ae0e..5b98ef45 100644 --- a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarState.kt +++ b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarState.kt @@ -1,8 +1,26 @@ package com.gamss.android.feature.calendar +import com.gamss.android.domain.card.Card import java.time.LocalDate +sealed interface CalendarCardLoadState { + data object Idle : CalendarCardLoadState + data object Loading : CalendarCardLoadState + data object Empty : CalendarCardLoadState + data class Content(val cards: List) : CalendarCardLoadState + data object Error : CalendarCardLoadState +} + +/** 카드가 삭제된 뒤의 목록 상태를 계산한다. [Content] 가 아니면 그대로 둔다. */ +fun CalendarCardLoadState.withoutCard(cardId: Long): CalendarCardLoadState { + if (this !is CalendarCardLoadState.Content) return this + val remaining = cards.filterNot { it.id == cardId } + return if (remaining.isEmpty()) CalendarCardLoadState.Empty else CalendarCardLoadState.Content(remaining) +} + data class CalendarState( val today: LocalDate, val selectedDate: LocalDate? = null, + val cardLoadState: CalendarCardLoadState = CalendarCardLoadState.Idle, + val selectedCard: Card? = null, ) diff --git a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarViewModel.kt b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarViewModel.kt index 41027a02..29cff938 100644 --- a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarViewModel.kt +++ b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarViewModel.kt @@ -1,14 +1,26 @@ package com.gamss.android.feature.calendar import androidx.lifecycle.ViewModel +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.card.Card +import com.gamss.android.domain.card.DeleteCardUseCase +import com.gamss.android.domain.card.GetCardsByDateUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.Syntax import org.orbitmvi.orbit.viewmodel.container import java.time.LocalDate import javax.inject.Inject @HiltViewModel -class CalendarViewModel @Inject constructor() : +class CalendarViewModel @Inject constructor( + private val getCardsByDate: GetCardsByDateUseCase, + private val deleteCard: DeleteCardUseCase, +) : ViewModel(), ContainerHost { @@ -16,8 +28,66 @@ class CalendarViewModel @Inject constructor() : CalendarState(today = LocalDate.now()), ) + private var cardLoadJob: Job? = null + fun selectDate(date: LocalDate) = intent { - val nextSelectedDate = date.takeIf { it != state.selectedDate } - reduce { state.copy(selectedDate = nextSelectedDate) } + if (date == state.selectedDate) { + cardLoadJob?.cancel() + reduce { state.copy(selectedDate = null, cardLoadState = CalendarCardLoadState.Idle) } + return@intent + } + + startCardLoad(date) + } + + fun retrySelectedDate() = intent { + state.selectedDate?.let { startCardLoad(it) } + } + + fun selectCard(card: Card) = intent { + reduce { state.copy(selectedCard = card) } + } + + fun dismissCardDetail() = intent { + reduce { state.copy(selectedCard = null) } + } + + fun discardSelectedCard() = intent { + val card = state.selectedCard ?: return@intent + reduce { state.copy(selectedCard = null) } + + when (deleteCard(card.id)) { + is AppResult.Success -> reduce { state.copy(cardLoadState = state.cardLoadState.withoutCard(card.id)) } + is AppResult.Failure -> postSideEffect(CalendarSideEffect.CardDiscardFailed) + } + } + + fun viewSelectedConversation() = intent { + val conversationId = state.selectedCard?.conversationId ?: return@intent + reduce { state.copy(selectedCard = null) } + postSideEffect(CalendarSideEffect.OpenChatRoom(conversationId)) + } + + private suspend fun Syntax.startCardLoad(date: LocalDate) { + cardLoadJob?.cancelAndJoin() + reduce { state.copy(selectedDate = date, cardLoadState = CalendarCardLoadState.Loading) } + + cardLoadJob = container.scope.launch(start = CoroutineStart.UNDISPATCHED) { + val loadState = when (val result = getCardsByDate(date)) { + is AppResult.Success -> + result.data + .takeIf { it.isNotEmpty() } + ?.let(CalendarCardLoadState::Content) + ?: CalendarCardLoadState.Empty + is AppResult.Failure -> + CalendarCardLoadState.Error + } + + intent { + if (state.selectedDate == date) { + reduce { state.copy(cardLoadState = loadState) } + } + } + } } } diff --git a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/component/CardDetailDialog.kt b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/component/CardDetailDialog.kt new file mode 100644 index 00000000..0f09b516 --- /dev/null +++ b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/component/CardDetailDialog.kt @@ -0,0 +1,145 @@ +package com.gamss.android.feature.calendar.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.calendar.R +import java.time.format.DateTimeFormatter + +/** + * 캘린더에서 카드를 탭했을 때 뜨는 상세 팝업. + * + * Figma의 Dim + Card 오버레이 구조를 그대로 옮긴다. 배경 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.calendar_card_discard), + secondaryActionLabel = stringResource(R.string.calendar_card_view_conversation), + shareActionLabel = stringResource(R.string.calendar_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.calendar_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.calendar_card_discard), + secondaryActionLabel = stringResource(R.string.calendar_card_view_conversation), + shareActionLabel = stringResource(R.string.calendar_card_share), + onPrimaryActionClick = {}, + onSecondaryActionClick = {}, + onShareClick = {}, + topEndAction = { CardCloseButton(onClick = {}) }, + ) +} diff --git a/feature/calendar/src/main/res/values/strings.xml b/feature/calendar/src/main/res/values/strings.xml index af2d6061..3d5e5d5d 100644 --- a/feature/calendar/src/main/res/values/strings.xml +++ b/feature/calendar/src/main/res/values/strings.xml @@ -5,4 +5,15 @@ 다음 달 %1$d년 %2$d월 %3$d일 %1$d년 %2$d월 %3$d일, 오늘 + %1$d월 %2$d일의 카드 + 이 날짜에는 카드가 없어요. + 카드를 불러오지 못했어요. + 다시 시도 + 카드 상세 보기 + 카드 닫기 + 기록 버리기 + 대화보기 + 공유하기 + 카드 공유하기 + 기록을 버리지 못했어요. 다시 시도해주세요 diff --git a/feature/calendar/src/test/java/com/gamss/android/feature/calendar/CalendarViewModelTest.kt b/feature/calendar/src/test/java/com/gamss/android/feature/calendar/CalendarViewModelTest.kt new file mode 100644 index 00000000..cf5e4dd1 --- /dev/null +++ b/feature/calendar/src/test/java/com/gamss/android/feature/calendar/CalendarViewModelTest.kt @@ -0,0 +1,219 @@ +package com.gamss.android.feature.calendar + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.card.Card +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.emotion.EmotionCharacter +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +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 + +@OptIn(ExperimentalCoroutinesApi::class) +class CalendarViewModelTest { + + @Test + fun `selecting a date loads its cards`() = runTest { + val selectedDate = LocalDate.of(2026, 8, 15) + val expectedCard = card(date = selectedDate) + val repository = RecordingCardRepository(expectedCard) + + CalendarViewModel(GetCardsByDateUseCase(repository), DeleteCardUseCase(repository)).test(this) { + containerHost.selectDate(selectedDate) + runCurrent() + runCurrent() + + val state = containerHost.container.stateFlow.value + assertEquals(selectedDate, repository.requestedDate) + assertEquals(selectedDate, state.selectedDate) + assertEquals(CalendarCardLoadState.Content(listOf(expectedCard)), state.cardLoadState) + + cancelAndIgnoreRemainingItems() + } + } + + @Test + fun `a failed request shows an error and can be retried`() = runTest { + val selectedDate = LocalDate.of(2026, 8, 15) + val repository = RecordingCardRepository(card(selectedDate), shouldFail = true) + + CalendarViewModel(GetCardsByDateUseCase(repository), DeleteCardUseCase(repository)).test(this) { + containerHost.selectDate(selectedDate) + runCurrent() + runCurrent() + assertEquals(CalendarCardLoadState.Error, containerHost.container.stateFlow.value.cardLoadState) + + repository.shouldFail = false + containerHost.retrySelectedDate() + runCurrent() + runCurrent() + + assertEquals(2, repository.requestCount) + assertEquals( + CalendarCardLoadState.Content(listOf(card(selectedDate))), + containerHost.container.stateFlow.value.cardLoadState, + ) + cancelAndIgnoreRemainingItems() + } + } + + @Test + fun `selecting a new date cancels the previous card request`() = runTest { + val firstDate = LocalDate.of(2026, 8, 15) + val secondDate = LocalDate.of(2026, 8, 16) + val repository = DelayingCardRepository() + + CalendarViewModel(GetCardsByDateUseCase(repository), DeleteCardUseCase(repository)).test(this) { + containerHost.selectDate(firstDate) + repository.awaitFirstRequest() + containerHost.selectDate(secondDate) + repository.awaitSecondRequest() + + assertEquals(listOf(firstDate, secondDate), repository.requestedDates) + assertEquals(listOf(firstDate), repository.cancelledDates) + assertEquals(secondDate, containerHost.container.stateFlow.value.selectedDate) + assertEquals(CalendarCardLoadState.Loading, containerHost.container.stateFlow.value.cardLoadState) + + containerHost.selectDate(secondDate) + runCurrent() + cancelAndIgnoreRemainingItems() + } + } + + @Test + fun `discarding a card deletes it and removes it from the list`() = runTest { + val selectedDate = LocalDate.of(2026, 8, 15) + val expectedCard = card(date = selectedDate) + val repository = RecordingCardRepository(expectedCard) + + CalendarViewModel(GetCardsByDateUseCase(repository), DeleteCardUseCase(repository)).test(this) { + containerHost.selectDate(selectedDate) + runCurrent() + runCurrent() + containerHost.selectCard(expectedCard) + runCurrent() + + containerHost.discardSelectedCard() + runCurrent() + + val state = containerHost.container.stateFlow.value + assertEquals(expectedCard.id, repository.deletedCardId) + assertEquals(null, state.selectedCard) + assertEquals(CalendarCardLoadState.Empty, state.cardLoadState) + + cancelAndIgnoreRemainingItems() + } + } + + @Test + fun `a failed discard posts a side effect and keeps the card in the list`() = runTest { + val selectedDate = LocalDate.of(2026, 8, 15) + val expectedCard = card(date = selectedDate) + val repository = RecordingCardRepository(expectedCard, deleteShouldFail = true) + + CalendarViewModel(GetCardsByDateUseCase(repository), DeleteCardUseCase(repository)).test(this) { + containerHost.selectDate(selectedDate) + runCurrent() + runCurrent() + containerHost.selectCard(expectedCard) + runCurrent() + + containerHost.discardSelectedCard() + runCurrent() + skipItems(4) + expectSideEffect(CalendarSideEffect.CardDiscardFailed) + + val state = containerHost.container.stateFlow.value + assertEquals(CalendarCardLoadState.Content(listOf(expectedCard)), state.cardLoadState) + + cancelAndIgnoreRemainingItems() + } + } + + private class RecordingCardRepository( + private val card: Card, + var shouldFail: Boolean = false, + var deleteShouldFail: Boolean = false, + ) : CardRepository { + var requestedDate: LocalDate? = null + private set + var requestCount: Int = 0 + private set + var deletedCardId: Long? = null + private set + + override suspend fun getCardsByDate(date: LocalDate): AppResult> { + requestedDate = date + requestCount++ + return if (shouldFail) { + AppResult.Failure(IllegalStateException("Network failure")) + } else { + AppResult.Success(listOf(card)) + } + } + + override suspend fun createCard( + conversationId: Long, + character: EmotionCharacter, + summary: String, + ): AppResult = error("Not used by the calendar") + + override suspend fun deleteCard(cardId: Long): AppResult { + deletedCardId = cardId + return if (deleteShouldFail) { + AppResult.Failure(IllegalStateException("Delete failure")) + } else { + AppResult.Success(Unit) + } + } + } + + private class DelayingCardRepository : CardRepository { + val requestedDates = mutableListOf() + val cancelledDates = mutableListOf() + private val firstRequestStarted = CompletableDeferred() + private val secondRequestStarted = CompletableDeferred() + + override suspend fun getCardsByDate(date: LocalDate): AppResult> { + requestedDates += date + when (requestedDates.size) { + 1 -> firstRequestStarted.complete(Unit) + 2 -> secondRequestStarted.complete(Unit) + } + try { + awaitCancellation() + } finally { + cancelledDates += date + } + } + + suspend fun awaitFirstRequest() = firstRequestStarted.await() + + suspend fun awaitSecondRequest() = secondRequestStarted.await() + + override suspend fun createCard( + conversationId: Long, + character: EmotionCharacter, + summary: String, + ): AppResult = error("Not used by the calendar") + + override suspend fun deleteCard(cardId: Long): AppResult = error("Not used by the calendar") + } + + private fun card(date: LocalDate) = Card( + id = 1L, + conversationId = 2L, + character = EmotionCharacter.ANGER, + emotionLabel = "분노", + summary = "비 때문에 하루가 꼬였어요", + message = "비 때문에 하루가 꼬였어요", + date = date, + ) +} diff --git a/feature/chat/build.gradle.kts b/feature/chat/build.gradle.kts index d8eced60..f1343e06 100644 --- a/feature/chat/build.gradle.kts +++ b/feature/chat/build.gradle.kts @@ -7,6 +7,7 @@ android { } dependencies { + implementation(projects.core.ui) implementation(projects.domain) implementation(libs.compose.material.icons.core) diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt index 0e082be6..8a10a84c 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow +import java.time.LocalDate /** * 채팅 ViewModel 조립을 한 곳에 둔다. 테스트마다 따로 조립하면 세션 구성이 갈라진다. @@ -209,6 +210,9 @@ internal class CountingCardRepository( var calls = 0 private set + override suspend fun getCardsByDate(date: LocalDate): AppResult> = + AppResult.Success(emptyList()) + override suspend fun createCard( conversationId: Long, character: EmotionCharacter, @@ -217,8 +221,20 @@ internal class CountingCardRepository( calls++ gate?.await() return failure?.let { AppResult.Failure(it) } - ?: AppResult.Success(Card(character = character, summary = summary, message = "대사")) + ?: AppResult.Success( + Card( + id = 1L, + conversationId = conversationId, + character = character, + emotionLabel = character.displayName, + summary = summary, + message = "대사", + date = LocalDate.of(2026, 8, 15), + ), + ) } + + override suspend fun deleteCard(cardId: Long): AppResult = error("채팅방 테스트에서 쓰지 않는다") } internal fun message(id: Long, conversationId: Long, sender: MessageSender, content: String) = Message( diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/EmotionCharacterCardMappingTest.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/EmotionCharacterCardMappingTest.kt new file mode 100644 index 00000000..4b071bc4 --- /dev/null +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/EmotionCharacterCardMappingTest.kt @@ -0,0 +1,27 @@ +package com.gamss.android.feature.chat + +import com.gamss.android.core.designsystem.card.GamssEmotionCardCharacter +import com.gamss.android.core.ui.card.toGamssEmotionCardCharacter +import com.gamss.android.domain.emotion.EmotionCharacter +import org.junit.Assert.assertEquals +import org.junit.Test + +class EmotionCharacterCardMappingTest { + + @Test + fun `all emotion characters map to their card characters`() { + val expectedCharacters = mapOf( + EmotionCharacter.JOY to GamssEmotionCardCharacter.JOY, + EmotionCharacter.ANGER to GamssEmotionCardCharacter.ANGER, + EmotionCharacter.ANXIETY to GamssEmotionCardCharacter.ANXIETY, + EmotionCharacter.SADNESS to GamssEmotionCardCharacter.SADNESS, + EmotionCharacter.QUIRKY to GamssEmotionCardCharacter.QUIRKY, + EmotionCharacter.PRICKLY to GamssEmotionCardCharacter.PRICKLY, + ) + + assertEquals( + expectedCharacters, + EmotionCharacter.entries.associateWith(EmotionCharacter::toGamssEmotionCardCharacter), + ) + } +} diff --git a/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt index fe46e190..32795419 100644 --- a/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt +++ b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt @@ -30,6 +30,7 @@ import com.gamss.android.domain.summary.SummarizeDiaryUseCase import com.gamss.android.domain.summary.UtteranceTokenCounter import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow +import java.time.LocalDate internal const val NEW_ROOM_ID = 42L @@ -119,11 +120,16 @@ internal class RecordingConversationRepository( } private object NoCardRepository : CardRepository { + override suspend fun getCardsByDate(date: LocalDate): AppResult> = + AppResult.Success(emptyList()) + override suspend fun createCard( conversationId: Long, character: EmotionCharacter, summary: String, ): AppResult = error("홈 테스트에서 쓰지 않는다") + + override suspend fun deleteCard(cardId: Long): AppResult = error("홈 테스트에서 쓰지 않는다") } private object PassThroughSummarizer : DiarySummarizer {