Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
d90501c
feat: 보관함 월별 조회를 감정별 카드 조회로 바꿉니다
soyeonLee126 Aug 20, 2026
afd1583
refactor: 보관함 상세가 카드를 통째로 다룹니다
soyeonLee126 Aug 20, 2026
0a515a1
refactor: 방금 버린 카드를 id로 지목합니다
soyeonLee126 Aug 20, 2026
af2e14b
refactor: 이름이 이미 말하는 주석을 걷어냅니다
soyeonLee126 Aug 21, 2026
904a1cb
refactor: 사실과 어긋난 주석과 계층 간 중복 주석을 걷어냅니다
soyeonLee126 Aug 21, 2026
34fcf4e
fix: 엔티티가 바뀐 카드 캐시 DB 버전을 올립니다
soyeonLee126 Aug 21, 2026
299905b
fix: 캐시와 서버가 같은 순서로 카드를 돌려주게 합니다
soyeonLee126 Aug 21, 2026
d813b00
fix: 달을 바꿔 돌아와도 버린 카드가 다시 떨어지지 않게 합니다
soyeonLee126 Aug 21, 2026
94a92c3
chore: 죽은 문자열과 사실과 어긋난 주석을 걷어냅니다
soyeonLee126 Aug 21, 2026
91eb84c
Merge remote-tracking branch 'origin/feature/add-card-motion' into fe…
soyeonLee126 Aug 21, 2026
c0daeda
fix: 캐시 완전성과 응답 관용성을 바로잡고 공유 버튼을 숨깁니다
soyeonLee126 Aug 21, 2026
56883d7
fix: 서버 경로도 감정과 달로 걸러 캐시와 답을 맞춥니다
soyeonLee126 Aug 21, 2026
d85095f
refactor: 카드 파쇄 후 목록을 다시 받지 않고 그 카드만 뺀다
soyeonLee126 Aug 21, 2026
94f200d
Merge remote-tracking branch 'origin/develop' into feature/card-month…
soyeonLee126 Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,12 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider {
// 보관함 상세도 탭 안쪽의 상세 화면이라 다른 상세들과 같은 슬라이드를 쓴다.
entry<ArchiveDetailKey>(metadata = detailSlideTransition) { key ->
// 대화방에 들렀다 돌아오면 이 컴포지션이 다시 만들어진다. 그때는 이미 비어 있어야 한다.
val droppedCardDate = remember { navigator.consumeDroppedCardDate() }
val hasShreddedCard = remember { navigator.consumeShreddedCard() }
val droppedCardId = remember { navigator.consumeDroppedCardId() }
val shreddedCardId = remember { navigator.consumeShreddedCardId() }
ArchiveDetailScreen(
emotion = key.emotion,
droppedCardDate = droppedCardDate,
hasShreddedCard = hasShreddedCard,
droppedCardId = droppedCardId,
shreddedCardId = shreddedCardId,
onBackClick = navigator::goBack,
onNavigateToCardDelete = { cardId -> navigator.navigate(CardDeleteKey(key.emotion, cardId)) },
)
Expand All @@ -159,15 +159,16 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider {
)
}
entry<CardDeleteKey>(metadata = detailSlideTransition) { key ->
val cardId = key.cardId
CardDeleteScreen(
emotion = key.emotion,
cardId = key.cardId,
cardId = cardId,
onBackClick = navigator::goBack,
// 칸을 통째로 비웠으면 돌아갈 자리가 비어 있으니 보관함 목록까지 걷어낸다.
onDeleteComplete = if (key.cardId == null) {
onDeleteComplete = if (cardId == null) {
navigator::finishCurrentFlow
} else {
navigator::finishShreddedCard
{ navigator.finishShreddedCard(cardId) }
},
)
}
Expand Down Expand Up @@ -196,8 +197,8 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider {
ChatRoomScreen(
conversationId = key.conversationId,
// 버린 카드가 어디로 갔는지 바로 보여 준다.
onCardDiscard = { emotion, date ->
navigator.openDroppedCard(ArchiveKey, ArchiveDetailKey(emotion), date)
onCardDiscard = { emotion, cardId ->
navigator.openDroppedCard(ArchiveKey, ArchiveDetailKey(emotion), cardId)
},
onCardSkip = navigator::goBack,
onBackClick = navigator::goBack,
Expand Down
43 changes: 13 additions & 30 deletions app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.gamss.android.app.navigation

import androidx.navigation3.runtime.NavKey
import java.time.LocalDate

/**
* NavigationState를 변경하는 앱 전용 navigator.
Expand All @@ -18,21 +17,16 @@ class Navigator(val state: NavigationState) {
* 때마다 방금 버린 것처럼 또 떨어진다. 프로세스가 죽으면 이 값도 사라지는데, 그때는 낙하를
* 건너뛰는 쪽이 맞다.
*/
private var droppedCardDate: LocalDate? = null
private var droppedCardId: Long? = null

fun consumeDroppedCardDate(): LocalDate? = droppedCardDate.also { droppedCardDate = null }
fun consumeDroppedCardId(): Long? = droppedCardId.also { droppedCardId = null }

/**
* 파쇄 화면이 카드를 지웠다는 일회성 신호. 위 날짜와 같은 이유로 key 에 싣지 않는다.
*/
private var hasShreddedCard = false
/** 위 카드 id 와 같은 이유로 key 에 싣지 않는다. */
private var shreddedCardId: Long? = null

fun consumeShreddedCard(): Boolean = hasShreddedCard.also { hasShreddedCard = false }
fun consumeShreddedCardId(): Long? = shreddedCardId.also { shreddedCardId = null }

/**
* 지정한 key로 이동한다.
* top-level key인지, 현재 탭인지, 상세 key인지에 따라 stack 갱신 규칙이 달라진다.
*/
/** top-level key인지, 현재 탭인지, 상세 key인지에 따라 stack 갱신 규칙이 달라진다. */
fun navigate(key: NavKey) {
when (key) {
state.currentTopLevelKey -> clearSubStack()
Expand All @@ -42,8 +36,6 @@ class Navigator(val state: NavigationState) {
}

/**
* 현재 위치에서 뒤로 이동한다.
*
* 현재 탭의 root 화면에서는 이전에 방문한 탭으로 돌아가고, 상세 화면에서는 현재 탭의
* sub stack에서 한 단계 pop한다.
*/
Expand All @@ -57,7 +49,6 @@ class Navigator(val state: NavigationState) {
}
}

/** 완료된 상세 흐름을 닫고 현재 탭의 첫 화면으로 돌아간다. */
fun finishCurrentFlow() {
clearSubStack()
}
Expand All @@ -66,11 +57,12 @@ class Navigator(val state: NavigationState) {
* 카드 한 장을 파쇄하고 원래 보던 칸으로 돌아간다.
*
* 남은 종이는 그대로 보여야 하므로 [finishCurrentFlow] 처럼 보관함 첫 화면까지 걷어내지 않는다.
* 대신 그 칸의 ViewModel 이 살아남아 지운 카드를 그대로 들고 있으므로, 목록을 다시 받으라고
* 신호를 남긴다.
* 대신 그 칸의 ViewModel 이 살아남아 지운 카드를 그대로 들고 있으므로, 어느 카드였는지를
* 남긴다. 파쇄가 끝났다는 건 서버에서 이미 지워졌다는 뜻이라, 그 한 장만 빼면 목록이 서버와
* 같아진다. 달·감정이 그대로인 목록을 통째로 다시 받을 이유가 없다.
*/
fun finishShreddedCard() {
hasShreddedCard = true
fun finishShreddedCard(cardId: Long) {
shreddedCardId = cardId
goBack()
}

Expand All @@ -81,27 +73,21 @@ class Navigator(val state: NavigationState) {
* currentSubStack 이 옮겨간 탭을 가리켜 손댈 수 없다. 옮겨간 탭도 root 까지 비우고 [detail]
* 하나만 올린다. 보관함에 다른 감정 칸이 열려 있었다면 뒤로 나갔을 때 그 칸이 다시 뜬다.
*/
fun openDroppedCard(topLevel: NavKey, detail: NavKey, droppedCardDate: LocalDate) {
this.droppedCardDate = droppedCardDate
fun openDroppedCard(topLevel: NavKey, detail: NavKey, droppedCardId: Long) {
this.droppedCardId = droppedCardId
clearSubStack()
goToTopLevel(topLevel)
clearSubStack()
goToKey(detail)
}

/**
* 현재 탭의 상세 화면으로 이동한다.
*/
private fun goToKey(key: NavKey) {
state.currentSubStack.apply {
remove(key)
add(key)
}
}

/**
* 다른 top-level 탭으로 이동한다.
*/
private fun goToTopLevel(key: NavKey) {
state.topLevelStack.apply {
if (key == state.startKey) {
Expand All @@ -113,9 +99,6 @@ class Navigator(val state: NavigationState) {
}
}

/**
* 현재 탭을 다시 선택했을 때 root 화면만 남긴다.
*/
private fun clearSubStack() {
state.currentSubStack.run {
if (size > 1) subList(1, size).clear()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ import com.gamss.android.feature.archive.navigation.ArchiveDetailKey
import com.gamss.android.feature.archive.navigation.ArchiveKey
import com.gamss.android.feature.carddelete.navigation.CardDeleteKey
import com.gamss.android.feature.chat.navigation.ChatKey
import com.gamss.android.feature.chat.navigation.ChatRoomKey
import com.gamss.android.feature.home.navigation.HomeKey
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class NavigatorTest {
Expand Down Expand Up @@ -71,32 +70,61 @@ class NavigatorTest {

/** 한 장만 지웠으면 남은 종이를 봐야 하므로 보던 칸으로 돌아간다. */
@Test
fun 카드_한_장_파쇄를_마치면_보관함_상세로_돌아가_목록을_다시_받는다() {
fun 카드_한_장_파쇄를_마치면_보관함_상세로_돌아가_지운_카드를_알린다() {
val navigator = navigator()
navigator.openArchiveDetail()
navigator.navigate(CardDeleteKey(EMOTION, CARD_ID))

navigator.finishShreddedCard()
navigator.finishShreddedCard(CARD_ID)

assertEquals(ArchiveDetailKey(EMOTION), navigator.state.currentKey)
assertTrue(navigator.consumeShreddedCard())
// 어느 카드를 뺄지 알려야 목록을 통째로 다시 받지 않고 그 한 장만 지울 수 있다.
assertEquals(CARD_ID, navigator.consumeShreddedCardId())
}

/** 신호를 비우지 않으면 그 칸에 다시 들어올 때마다 목록을 또 받는다. */
/** 끝난 대화방이 남으면 뒤로 나갔을 때 이어 쓸 수 없는 방으로 돌아간다. */
@Test
fun 버린_카드를_열면_대화방을_걷어내고_보관함_상세만_올린다() {
val navigator = navigator()
navigator.navigate(ChatKey)
navigator.navigate(ChatRoomKey(CONVERSATION_ID))

navigator.openDroppedCard(ArchiveKey, ArchiveDetailKey(EMOTION), CARD_ID)

assertEquals(ArchiveDetailKey(EMOTION), navigator.state.currentKey)
assertEquals(
listOf(ArchiveKey, ArchiveDetailKey(EMOTION)),
navigator.state.currentSubStack.toList(),
)
assertEquals(listOf(ChatKey), navigator.state.subStacks.getValue(ChatKey).toList())
}

/** 신호를 비우지 않으면 그 칸에 다시 들어올 때마다 같은 종이가 또 떨어진다. */
@Test
fun 버린_카드_신호는_한_번만_읽힌다() {
val navigator = navigator()
navigator.openDroppedCard(ArchiveKey, ArchiveDetailKey(EMOTION), CARD_ID)

assertEquals(CARD_ID, navigator.consumeDroppedCardId())
assertEquals(null, navigator.consumeDroppedCardId())
}

/** 신호를 비우지 않으면 그 칸에 다시 들어올 때마다 같은 카드를 또 뺀다. */
@Test
fun 파쇄_신호는_한_번만_읽힌다() {
val navigator = navigator()
navigator.openArchiveDetail()
navigator.navigate(CardDeleteKey(EMOTION, CARD_ID))
navigator.finishShreddedCard()
navigator.finishShreddedCard(CARD_ID)

navigator.consumeShreddedCard()
navigator.consumeShreddedCardId()

assertFalse(navigator.consumeShreddedCard())
assertEquals(null, navigator.consumeShreddedCardId())
}

private companion object {
const val CARD_ID = 7L
const val CONVERSATION_ID = 42L
val EMOTION = EmotionCharacter.ANGER
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ 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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.PathEffect
Expand Down Expand Up @@ -133,6 +134,8 @@ private fun GamssImageCardEmotionDarkPreview() {
*
* 카드의 고정 구조와 감정별 캐릭터 선택은 이 컴포넌트가 맡고, 문구와 사용자 동작만 호출부가 제공한다.
* 따라서 화면마다 [GamssImageCard]의 간격과 텍스트 스타일을 다시 조합할 필요가 없다.
*
* @param isShareVisible 공유 줄을 보일지. 끄면 자리만 남기고 투명하게 두어 카드 안 간격이 바뀌지 않는다.
*/
@Composable
@Suppress("LongParameterList")
Expand All @@ -149,6 +152,7 @@ fun GamssEmotionCard(
onShareClick: () -> Unit,
modifier: Modifier = Modifier,
shape: Shape = RectangleShape,
isShareVisible: Boolean = true,
topEndAction: @Composable BoxScope.() -> Unit = {},
) {
GamssImageCard(
Expand Down Expand Up @@ -195,10 +199,12 @@ fun GamssEmotionCard(
)
}
Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200))
// 숨겨도 자리는 남긴다. 빼 버리면 카드 안 다른 요소가 아래로 밀려 시안과 어긋난다.
Row(
modifier = Modifier
.fillMaxWidth()
.noRippleClickableIfNotNull(onShareClick),
.alpha(if (isShareVisible) 1f else 0f)
.noRippleClickableIfNotNull(onShareClick.takeIf { isShareVisible }),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "ed660b41190bf72caaaa9ce9e9647cd2",
"entities": [
{
"tableName": "cards",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `conversation_id` INTEGER NOT NULL, `emotion` TEXT NOT NULL, `emotion_label` TEXT NOT NULL, `summary` TEXT NOT NULL, `message` TEXT NOT NULL, `date` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "conversationId",
"columnName": "conversation_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emotion",
"columnName": "emotion",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emotionLabel",
"columnName": "emotion_label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "summary",
"columnName": "summary",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_cards_conversation_id",
"unique": true,
"columnNames": [
"conversation_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cards_conversation_id` ON `${TABLE_NAME}` (`conversation_id`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ed660b41190bf72caaaa9ce9e9647cd2')"
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ import com.gamss.android.data.local.card.model.CardEntity
@Dao
internal interface CardDao {

/** indexInDate 순서를 그대로 복원해야 selectCard 가 고르는 순번이 서버 응답과 어긋나지 않는다. */
@Query("SELECT * FROM cards WHERE date = :date ORDER BY index_in_date ASC")
suspend fun findByDate(date: String): List<CardEntity>
/** 날짜를 `yyyy-MM-dd` 문자열로 담으므로 `yyyy-MM` 접두사로 그 달을 고른다. */
@Query(
"SELECT * FROM cards WHERE emotion = :emotion AND date LIKE :yearMonthPrefix || '%' " +
"ORDER BY date ASC, id ASC",
)
suspend fun findByEmotionAndMonth(emotion: String, yearMonthPrefix: String): List<CardEntity>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(cards: List<CardEntity>)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.gamss.android.data.local.card

import com.gamss.android.data.local.card.model.CardEntity
import java.time.LocalDate
import java.time.YearMonth

internal interface CardLocalDataSource {
suspend fun findByDate(date: LocalDate): List<CardEntity>
suspend fun findByEmotionAndMonth(emotion: String, yearMonth: YearMonth): List<CardEntity>
suspend fun upsertAll(cards: List<CardEntity>)
suspend fun deleteAll()
}
Loading
Loading