From ac59c9ece268dbd8689211d6fe64c5ad81905d30 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:42:26 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=EC=B9=B4=EB=93=9C=20=EA=B0=9C?= =?UTF-8?q?=EB=B3=84=20=ED=8C=8C=EC=87=84=20=EC=82=AD=EC=A0=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + .../com/gamss/android/app/main/MainScreen.kt | 10 ++ .../gamss/android/app/navigation/Navigator.kt | 5 + .../core/designsystem/button/GamssButton.kt | 9 +- .../designsystem/button/GamssButtonVariant.kt | 1 + .../res/drawable-nodpi/img_paper_strip.png | Bin 0 -> 36249 bytes .../android/data/remote/card/CardService.kt | 6 + .../card/model/response/CardResponse.kt | 15 +- .../android/data/repository/ApiErrorCodes.kt | 1 + .../data/repository/CardRepositoryImpl.kt | 16 ++- .../data/repository/CardRepositoryImplTest.kt | 70 +++++++++ .../com/gamss/android/domain/card/Card.kt | 2 + .../android/domain/card/CardRepository.kt | 3 + .../android/domain/card/DeleteCardUseCase.kt | 13 ++ .../domain/card/CreateCardUseCaseTest.kt | 4 +- .../domain/card/DeleteCardUseCaseTest.kt | 36 +++++ .../conversation/ConversationSessionTest.kt | 4 +- feature/carddelete/build.gradle.kts | 15 ++ .../feature/carddelete/CardDeleteScreen.kt | 136 ++++++++++++++++++ .../carddelete/CardDeleteSideEffect.kt | 6 + .../feature/carddelete/CardDeleteState.kt | 10 ++ .../feature/carddelete/CardDeleteViewModel.kt | 48 +++++++ .../feature/carddelete/ShredAnimationSpec.kt | 11 ++ .../carddelete/component/ShredStatusRow.kt | 123 ++++++++++++++++ .../carddelete/component/ShredStrips.kt | 80 +++++++++++ .../carddelete/navigation/CardDeleteKey.kt | 8 ++ .../src/main/res/values/strings.xml | 11 ++ .../carddelete/CardDeleteViewModelTest.kt | 62 ++++++++ .../android/feature/chat/ChatRoomScreen.kt | 11 +- feature/chat/src/main/res/values/strings.xml | 1 + .../android/feature/chat/ChatRoomTestFakes.kt | 4 +- .../android/feature/home/HomeTestFakes.kt | 2 + feature/setting/build.gradle.kts | 2 +- settings.gradle.kts | 1 + 34 files changed, 713 insertions(+), 14 deletions(-) create mode 100644 core/designsystem/src/main/res/drawable-nodpi/img_paper_strip.png create mode 100644 data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/card/DeleteCardUseCase.kt create mode 100644 domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardUseCaseTest.kt create mode 100644 feature/carddelete/build.gradle.kts create mode 100644 feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt create mode 100644 feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteSideEffect.kt create mode 100644 feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteState.kt create mode 100644 feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt create mode 100644 feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/ShredAnimationSpec.kt create mode 100644 feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStatusRow.kt create mode 100644 feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStrips.kt create mode 100644 feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt create mode 100644 feature/carddelete/src/main/res/values/strings.xml create mode 100644 feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f8a45a23..19c4cf54 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,7 @@ dependencies { implementation(projects.feature.calendar) implementation(projects.feature.login) implementation(projects.feature.setting) + implementation(projects.feature.carddelete) implementation(projects.feature.webview) implementation(libs.compose.material.icons.core) 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 90dc8f2d..e8ae85ad 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 @@ -35,6 +35,8 @@ import com.gamss.android.feature.chat.navigation.ChatRoomKey import com.gamss.android.feature.home.HomeScreen import com.gamss.android.feature.home.navigation.HomeKey import com.gamss.android.feature.setting.accountinfo.AccountInfoScreen +import com.gamss.android.feature.carddelete.CardDeleteScreen +import com.gamss.android.feature.carddelete.navigation.CardDeleteKey import com.gamss.android.feature.setting.main.SettingScreen import com.gamss.android.feature.setting.navigation.AccountInfoKey import com.gamss.android.feature.setting.navigation.NicknameChangeKey @@ -108,6 +110,13 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { onPrivacyPolicyClick = { navigator.navigate(WebViewKey(GamssWebPage.PrivacyPolicy)) }, ) } + entry { key -> + CardDeleteScreen( + cardId = key.cardId, + onBackClick = navigator::goBack, + onDeleteComplete = navigator::finishCurrentFlow, + ) + } entry { AccountInfoScreen( onBackClick = navigator::goBack, @@ -133,6 +142,7 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { ChatRoomScreen( conversationId = key.conversationId, onCardClose = navigator::goBack, + onCardDeleteClick = { cardId -> navigator.navigate(CardDeleteKey(cardId)) }, ) } } diff --git a/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt b/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt index b3fd1e73..e77d091a 100644 --- a/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt +++ b/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt @@ -40,6 +40,11 @@ class Navigator(val state: NavigationState) { } } + /** 완료된 상세 흐름을 닫고 현재 탭의 첫 화면으로 돌아간다. */ + fun finishCurrentFlow() { + clearSubStack() + } + /** * 현재 탭의 상세 화면으로 이동한다. */ diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButton.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButton.kt index 577ad414..d67e0815 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButton.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButton.kt @@ -38,15 +38,17 @@ fun GamssButton( modifier: Modifier = Modifier, variant: GamssButtonVariant = GamssButtonVariant.Primary, enabled: Boolean = true, + isProcessing: Boolean = false, ) { val colors = gamssButtonColors(variant = variant, enabled = enabled) + val isClickable = enabled && !isProcessing Box( modifier = modifier .clip(RoundedCornerShape(GamssTheme.radius.radius200)) .background(colors.containerColor) .clickable( - enabled = enabled, + enabled = isClickable, role = Role.Button, indication = null, interactionSource = null, @@ -84,6 +86,11 @@ private fun gamssButtonColors( contentColor = GamssTheme.colors.gray025, ) + GamssButtonVariant.PrimaryDark -> GamssButtonColors( + containerColor = GamssTheme.colors.gray900, + contentColor = GamssTheme.colors.gray025, + ) + GamssButtonVariant.Secondary -> GamssButtonColors( containerColor = GamssTheme.colors.gray100, contentColor = GamssTheme.colors.gray600, diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButtonVariant.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButtonVariant.kt index 3a4d7649..1fd2e333 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButtonVariant.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButtonVariant.kt @@ -2,6 +2,7 @@ package com.gamss.android.core.designsystem.button enum class GamssButtonVariant { Primary, + PrimaryDark, Secondary, Destructive, } diff --git a/core/designsystem/src/main/res/drawable-nodpi/img_paper_strip.png b/core/designsystem/src/main/res/drawable-nodpi/img_paper_strip.png new file mode 100644 index 0000000000000000000000000000000000000000..eec0f8461ad41498cd801c74ef4257fce3c407aa GIT binary patch literal 36249 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 literal 0 HcmV?d00001 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..29b5bfee 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,17 @@ 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.POST +import retrofit2.http.Path internal interface CardService { /** 종료된 채팅방에만 만들 수 있다. 대사는 서버가 생성한다. */ @POST("/api/cards") suspend fun createCard(@Body request: CreateCardRequest): ApiResponse + + /** 해당 카드와 카드가 나온 채팅방을 함께 삭제한다. */ + @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..03e5fd3c 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,22 @@ package com.gamss.android.data.remote.card.model.response import com.gamss.android.domain.card.Card -import com.gamss.android.domain.emotion.EmotionCharacter +import com.gamss.android.data.remote.emotion.toEmotionCharacter import kotlinx.serialization.Serializable @Serializable internal data class CardResponse( + val id: Long, + val conversationId: Long, + val emotion: String, val summary: String, val message: String, ) -/** - * 서버는 우리가 보낸 감정을 그대로 대표 감정으로 쓰므로 응답 문자열을 다시 해석하지 않고 - * 보낸 캐릭터를 그대로 쓴다. 해석 실패로 카드를 잃는 경로를 없앤다. - */ -internal fun CardResponse.toDomain(character: EmotionCharacter): Card = Card( - character = character, +internal fun CardResponse.toDomain(): Card = Card( + id = id, + conversationId = conversationId, + character = checkNotNull(emotion.toEmotionCharacter()) { "Unknown card emotion: $emotion" }, summary = summary, message = message, ) 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..9ccf21dd 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 @@ -6,6 +6,7 @@ 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.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 @@ -31,7 +32,8 @@ 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() } return when (result) { is AppResult.Success -> result @@ -44,4 +46,16 @@ 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..68ba558e --- /dev/null +++ b/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt @@ -0,0 +1,70 @@ +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 com.gamss.android.domain.auth.SessionExpiredException +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CardRepositoryImplTest { + + private val cardService: CardService = mockk() + private val repository = CardRepositoryImpl(cardService) + + @Test + fun `카드 ID로 삭제 요청을 전달한다`() = runTest { + coEvery { cardService.deleteCard(CARD_ID) } returns ApiResponse( + success = true, + ) + + assertEquals(AppResult.Success(Unit), repository.deleteCard(CARD_ID)) + } + + @Test + fun `200이어도 실패 envelope면 실패로 전달한다`() = runTest { + coEvery { cardService.deleteCard(CARD_ID) } returns ApiResponse( + success = false, + error = ApiError(code = "CARD_DELETE_FAILED", message = "삭제 실패"), + ) + + val result = repository.deleteCard(CARD_ID) + + assertEquals( + "CARD_DELETE_FAILED", + ((result as AppResult.Failure).throwable as ApiException).code, + ) + } + + @Test + fun `실패 envelope의 인증 만료는 세션 만료로 전달한다`() = runTest { + coEvery { cardService.deleteCard(CARD_ID) } returns ApiResponse( + success = false, + error = ApiError(code = "EXPIRED_TOKEN", message = "만료"), + ) + + val result = repository.deleteCard(CARD_ID) + + assertTrue((result as AppResult.Failure).throwable is SessionExpiredException) + } + + @Test + fun `이미 삭제된 카드 응답은 삭제 완료로 처리한다`() = runTest { + coEvery { cardService.deleteCard(CARD_ID) } returns ApiResponse( + success = false, + error = ApiError(code = "CARD_ALREADY_DELETED", message = "이미 삭제됨"), + ) + + assertEquals(AppResult.Success(Unit), repository.deleteCard(CARD_ID)) + } + + private companion object { + const val CARD_ID = 42L + } +} 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..17ba4cfd 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 @@ -8,6 +8,8 @@ import com.gamss.android.domain.emotion.EmotionCharacter * @param summary 클라이언트가 보낸 압축본. 카드 제목으로 그대로 노출된다. */ data class Card( + val id: Long, + val conversationId: Long, val character: EmotionCharacter, val summary: String, val message: String, 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..4a7b4f43 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 @@ -11,4 +11,7 @@ interface CardRepository { 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..252cd747 --- /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 + +/** 카드 ID로 한 장을 삭제한다. 카드가 나온 채팅방도 함께 삭제되며 되돌릴 수 없다. */ +class DeleteCardUseCase @Inject constructor( + private val cardRepository: CardRepository, +) : UseCase> { + + override suspend fun invoke(params: Long): AppResult = cardRepository.deleteCard(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..272ea41f 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 @@ -19,8 +19,10 @@ 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, summary = summary, message = "대사")) } + + override suspend fun deleteCard(cardId: Long): AppResult = AppResult.Success(Unit) } @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..074b195c --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardUseCaseTest.kt @@ -0,0 +1,36 @@ +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 + +class DeleteCardUseCaseTest { + + private class RecordingRepository : CardRepository { + var deletedCardId: Long? = null + + 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 카드_ID를_리포지토리에_그대로_위임한다() = runBlocking { + val repository = RecordingRepository() + + val result = DeleteCardUseCase(repository)(CARD_ID) + + assertEquals(CARD_ID, repository.deletedCardId) + assertEquals(AppResult.Success(Unit), result) + } + + private companion object { + const val CARD_ID = 42L + } +} 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 21502d2c..0fc1145c 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 @@ -218,7 +218,9 @@ class ConversationSessionTest { 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, summary = summary, message = "대사")) + + override suspend fun deleteCard(cardId: Long): AppResult = AppResult.Success(Unit) } private class FakeConversationRepository( diff --git a/feature/carddelete/build.gradle.kts b/feature/carddelete/build.gradle.kts new file mode 100644 index 00000000..0b08f132 --- /dev/null +++ b/feature/carddelete/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.gamss.android.feature) +} + +android { + namespace = "com.gamss.android.feature.carddelete" +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.ui) + implementation(projects.domain) + + testImplementation(libs.mockk) +} diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt new file mode 100644 index 00000000..aa4509bb --- /dev/null +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt @@ -0,0 +1,136 @@ +package com.gamss.android.feature.carddelete + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.runtime.Composable +import androidx.compose.runtime.getValue +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.gamss.android.core.designsystem.button.GamssButton +import com.gamss.android.core.designsystem.button.GamssButtonVariant +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigation +import com.gamss.android.feature.carddelete.component.ShredStatusRow +import com.gamss.android.feature.carddelete.component.ShredStrips +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@Composable +fun CardDeleteScreen( + cardId: Long, + onBackClick: () -> Unit, + onDeleteComplete: () -> Unit, + modifier: Modifier = Modifier, + viewModel: CardDeleteViewModel = hiltViewModel(), +) { + val state by viewModel.collectAsState() + val context = LocalContext.current + + BackHandler(enabled = state.isDeleting || state.isCompleted) { + if (state.isCompleted) onDeleteComplete() + } + + viewModel.collectSideEffect { sideEffect -> + Toast.makeText(context, sideEffect.toMessage(context), Toast.LENGTH_SHORT).show() + } + + CardDeleteContent( + state = state, + onBackClick = when { + state.isDeleting -> ({ Unit }) + state.isCompleted -> onDeleteComplete + else -> onBackClick + }, + onDeleteComplete = onDeleteComplete, + onShredTap = { viewModel.onShredTap(cardId) }, + modifier = modifier, + ) +} + +/** + * 확인 다이얼로그 없이, 버튼을 누를 때마다([onShredTap]) 종이가 한 단계씩 내려간다 — 연타 자체가 + * 확인 절차를 대신한다. 끝까지 내려가면 ViewModel이 그 시점에 실제 삭제를 요청한다. + */ +@Composable +private fun CardDeleteContent( + state: CardDeleteState, + onBackClick: () -> Unit, + onDeleteComplete: () -> Unit, + onShredTap: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(GamssTheme.colors.gray025), + ) { + Column(modifier = Modifier.fillMaxSize()) { + GamssTopNavigation( + title = stringResource(R.string.card_delete_title), + showLeftIcon = true, + onLeftIconClick = onBackClick, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + ShredStrips( + progress = state.shredProgress, + modifier = Modifier + .fillMaxSize() + .padding(top = ShredStatusRowHeight - ShredStripOverlayHeight), + ) + ShredStatusRow( + isShredding = state.isShredding, + modifier = Modifier + .fillMaxWidth() + .height(ShredStatusRowHeight), + ) + } + } + + GamssButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(GamssTheme.spacing.spacing400), + label = stringResource( + if (state.isCompleted) R.string.card_delete_complete_button else R.string.card_delete_shred_button, + ), + onClick = if (state.isCompleted) onDeleteComplete else onShredTap, + variant = if (state.isCompleted) GamssButtonVariant.Destructive else GamssButtonVariant.PrimaryDark, + isProcessing = state.isDeleting, + ) + } +} + +private fun CardDeleteSideEffect.toMessage(context: android.content.Context): String = + when (this) { + CardDeleteSideEffect.ShredSuccess -> context.getString(R.string.card_delete_success_message) + CardDeleteSideEffect.ShredFailure -> context.getString(R.string.card_delete_failure_message) + } + +private val ShredStatusRowHeight = 90.dp +private val ShredStripOverlayHeight = 4.dp + +@Preview(name = "Idle", showBackground = true, widthDp = 402, heightDp = 874) +@Composable +@Suppress("UnusedPrivateMember") +private fun CardDeleteContentIdlePreview() { + GamssTheme(darkTheme = false) { + CardDeleteContent(state = CardDeleteState(), onBackClick = {}, onDeleteComplete = {}, onShredTap = {}) + } +} diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteSideEffect.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteSideEffect.kt new file mode 100644 index 00000000..dffd5320 --- /dev/null +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteSideEffect.kt @@ -0,0 +1,6 @@ +package com.gamss.android.feature.carddelete + +sealed interface CardDeleteSideEffect { + data object ShredSuccess : CardDeleteSideEffect + data object ShredFailure : CardDeleteSideEffect +} diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteState.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteState.kt new file mode 100644 index 00000000..f9c05892 --- /dev/null +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteState.kt @@ -0,0 +1,10 @@ +package com.gamss.android.feature.carddelete + +data class CardDeleteState( + val shredTapCount: Int = 0, + val isDeleting: Boolean = false, + val isCompleted: Boolean = false, +) { + val isShredding: Boolean get() = !isCompleted && (shredTapCount > 0 || isDeleting) + val shredProgress: Float get() = (shredTapCount.toFloat() / SHRED_TOTAL_TAPS).coerceIn(0f, 1f) +} diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt new file mode 100644 index 00000000..41669607 --- /dev/null +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt @@ -0,0 +1,48 @@ +package com.gamss.android.feature.carddelete + +import androidx.lifecycle.ViewModel +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.card.DeleteCardUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import javax.inject.Inject + +@HiltViewModel +class CardDeleteViewModel @Inject constructor( + private val deleteCard: DeleteCardUseCase, +) : ViewModel(), ContainerHost { + + override val container = container(CardDeleteState()) + + /** 누를 때마다 종이가 한 단계씩 내려간다. 끝까지 내려가면 전달받은 카드만 삭제한다. */ + fun onShredTap(cardId: Long) = intent { + if (state.isDeleting || state.shredTapCount >= SHRED_TOTAL_TAPS) return@intent + + val nextTapCount = state.shredTapCount + 1 + reduce { state.copy(shredTapCount = nextTapCount) } + if (nextTapCount < SHRED_TOTAL_TAPS) return@intent + + reduce { state.copy(isDeleting = true) } + val result = coroutineScope { + val minimumHold = launch { delay(SHRED_COMPLETE_HOLD_MS) } + val outcome = deleteCard(cardId) + minimumHold.join() + outcome + } + when (result) { + is AppResult.Success -> { + reduce { state.copy(isDeleting = false, isCompleted = true) } + postSideEffect(CardDeleteSideEffect.ShredSuccess) + } + + is AppResult.Failure -> { + reduce { state.copy(isDeleting = false, shredTapCount = 0) } + postSideEffect(CardDeleteSideEffect.ShredFailure) + } + } + } +} diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/ShredAnimationSpec.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/ShredAnimationSpec.kt new file mode 100644 index 00000000..63641ddd --- /dev/null +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/ShredAnimationSpec.kt @@ -0,0 +1,11 @@ +package com.gamss.android.feature.carddelete + +/** 파쇄하기를 몇 번 눌러야 끝까지 내려가는지와, 한 번 누를 때마다 종이가 움직이는 시간. */ +internal const val SHRED_TOTAL_TAPS = 6 +internal const val SHRED_TAP_STEP_DURATION_MS = 220 + +/** 전체 진행도 중 찢기(위쪽 고정, 높이만 자람) 구간이 차지하는 비율. 나머지는 낙하 구간이다. */ +internal const val SHRED_TEAR_FRACTION = 0.3f + +/** 삭제 요청과 병행하되, 다 내려간 모습을 최소로 유지하는 시간. */ +internal const val SHRED_COMPLETE_HOLD_MS = 500L diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStatusRow.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStatusRow.kt new file mode 100644 index 00000000..85820c31 --- /dev/null +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStatusRow.kt @@ -0,0 +1,123 @@ +package com.gamss.android.feature.carddelete.component + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.feature.carddelete.R + +@Composable +fun ShredStatusRow( + isShredding: Boolean, + modifier: Modifier = Modifier, +) { + val statusDescription = stringResource( + if (isShredding) R.string.card_delete_status_shredding else R.string.card_delete_status_ready, + ) + val bottomBorderColor = GamssTheme.colors.gray400 + val activeColor by animateColorAsState( + targetValue = if (isShredding) GamssTheme.colors.green else GamssTheme.colors.apricot, + label = "ShredStatusColor", + ) + + Row( + modifier = modifier + .fillMaxWidth() + .background(GamssTheme.colors.gray100) + .semantics { stateDescription = statusDescription } + .drawBehind { + val strokeWidth = 2.dp.toPx() + drawLine( + color = bottomBorderColor, + start = Offset(0f, size.height - strokeWidth / 2), + end = Offset(size.width, size.height - strokeWidth / 2), + strokeWidth = strokeWidth, + ) + } + .padding(horizontal = GamssTheme.spacing.spacing400, vertical = 25.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + StatusPillToggle(isOn = isShredding) + Spacer(modifier = Modifier.weight(1f)) + Box( + modifier = Modifier + .size(StatusIndicatorSize) + .clip(CircleShape) + .background(GamssTheme.colors.gray300) + .border(1.dp, GamssTheme.colors.gray400, CircleShape), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(StatusDotSize) + .clip(CircleShape) + .background(activeColor), + ) + } + } +} + +@Composable +private fun StatusPillToggle(isOn: Boolean, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .clip(RoundedCornerShape(GamssTheme.radius.radiusFull)) + .background(GamssTheme.colors.gray300) + .border(1.dp, GamssTheme.colors.gray400, RoundedCornerShape(GamssTheme.radius.radiusFull)) + .padding(ToggleTrackPadding), + ) { + ToggleCell(stringResource(R.string.card_delete_status_off), !isOn, GamssTheme.colors.apricot) + ToggleCell(stringResource(R.string.card_delete_status_on), isOn, GamssTheme.colors.green) + } +} + +@Composable +private fun ToggleCell(label: String, selected: Boolean, selectedColor: Color) { + val backgroundColor by animateColorAsState( + targetValue = if (selected) selectedColor else Color.Transparent, + label = "ToggleCellBackground", + ) + Box( + modifier = Modifier + .width(ToggleCellWidth) + .height(ToggleCellHeight) + .clip(RoundedCornerShape(GamssTheme.radius.radiusFull)) + .background(backgroundColor), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = GamssTheme.typography.body5Medium, + color = if (selected) GamssTheme.colors.white else GamssTheme.colors.gray500, + ) + } +} + +private val StatusIndicatorSize = 30.dp +private val StatusDotSize = 18.dp +private val ToggleTrackPadding = 4.dp +private val ToggleCellWidth = 42.dp +private val ToggleCellHeight = 32.dp diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStrips.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStrips.kt new file mode 100644 index 00000000..4d0b79dc --- /dev/null +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStrips.kt @@ -0,0 +1,80 @@ +package com.gamss.android.feature.carddelete.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +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.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.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.R as DesignSystemR +import com.gamss.android.feature.carddelete.SHRED_TAP_STEP_DURATION_MS +import com.gamss.android.feature.carddelete.SHRED_TEAR_FRACTION + +@Composable +fun ShredStrips(progress: Float, modifier: Modifier = Modifier) { + val displayProgress = remember { Animatable(0f) } + + LaunchedEffect(progress) { + if (progress < displayProgress.value) displayProgress.snapTo(progress) + else displayProgress.animateTo(progress, tween(SHRED_TAP_STEP_DURATION_MS, easing = FastOutSlowInEasing)) + } + + BoxWithConstraints(modifier = modifier.clipToBounds()) { + val stripMaxHeight = maxHeight * STRIP_MAX_HEIGHT_FRACTION + val fallDistance = maxHeight + stripMaxHeight + val tearProgress = (displayProgress.value / SHRED_TEAR_FRACTION).coerceIn(0f, 1f) + val fallProgress = ((displayProgress.value - SHRED_TEAR_FRACTION) / (1f - SHRED_TEAR_FRACTION)) + .coerceIn(0f, 1f) + + Row( + modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter).padding(horizontal = StripRowHorizontalPadding), + horizontalArrangement = Arrangement.spacedBy(StripGap), + ) { + LaneSpecs.forEach { lane -> + ShredStrip( + modifier = Modifier.weight(1f), + heightFraction = tearProgress, + fallFraction = ((fallProgress - lane.fallStartOffset) / (1f - lane.fallStartOffset)).coerceIn(0f, 1f), + fallDistance = fallDistance, + targetHeight = stripMaxHeight * lane.targetHeightRatio, + ) + } + } + } +} + +@Composable +private fun ShredStrip(heightFraction: Float, fallFraction: Float, fallDistance: Dp, targetHeight: Dp, modifier: Modifier) { + val currentHeight = StripBaseHeight + (targetHeight - StripBaseHeight) * heightFraction + Image( + painter = painterResource(DesignSystemR.drawable.img_paper_strip), + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = modifier.height(currentHeight).graphicsLayer { translationY = fallDistance.toPx() * fallFraction }, + ) +} + +private data class LaneSpec(val targetHeightRatio: Float, val fallStartOffset: Float) +private val LaneSpecs = listOf( + LaneSpec(0.55f, 0.00f), LaneSpec(0.71f, 0.05f), LaneSpec(0.48f, 0.02f), LaneSpec(0.90f, 0.08f), + LaneSpec(0.71f, 0.01f), LaneSpec(0.55f, 0.06f), LaneSpec(1.00f, 0.00f), LaneSpec(0.80f, 0.07f), +) +private val StripBaseHeight = 28.dp +private const val STRIP_MAX_HEIGHT_FRACTION = 0.55f +private val StripGap = 10.dp +private val StripRowHorizontalPadding = 24.dp diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt new file mode 100644 index 00000000..64162152 --- /dev/null +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt @@ -0,0 +1,8 @@ +package com.gamss.android.feature.carddelete.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +/** 삭제 대상은 화면 밖에서 선택하고, 이 키에는 안정적인 식별자만 전달한다. */ +@Serializable +data class CardDeleteKey(val cardId: Long) : NavKey diff --git a/feature/carddelete/src/main/res/values/strings.xml b/feature/carddelete/src/main/res/values/strings.xml new file mode 100644 index 00000000..b43b732e --- /dev/null +++ b/feature/carddelete/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + 비우기 + 파쇄하기 + 완료 + OFF + ON + 파쇄 준비됨 + 파쇄 중 + 카드를 파쇄했어요 + 파쇄에 실패했어요. 다시 시도해주세요 + diff --git a/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt b/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt new file mode 100644 index 00000000..47002274 --- /dev/null +++ b/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt @@ -0,0 +1,62 @@ +package com.gamss.android.feature.carddelete + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.card.DeleteCardUseCase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.orbitmvi.orbit.test.test + +@OptIn(ExperimentalCoroutinesApi::class) +class CardDeleteViewModelTest { + + private val deleteCard: DeleteCardUseCase = mockk() + + @Test + fun `여섯 번째 탭에서 전달받은 카드만 한 번 삭제하고 완료 상태가 된다`() = runTest { + coEvery { deleteCard(CARD_ID) } returns AppResult.Success(Unit) + + CardDeleteViewModel(deleteCard).test(this) { + repeat(SHRED_TOTAL_TAPS - 1) { tapIndex -> + containerHost.onShredTap(CARD_ID) + expectState { copy(shredTapCount = tapIndex + 1) } + } + + containerHost.onShredTap(CARD_ID) + expectState { copy(shredTapCount = SHRED_TOTAL_TAPS) } + expectState { copy(shredTapCount = SHRED_TOTAL_TAPS, isDeleting = true) } + advanceTimeBy(SHRED_COMPLETE_HOLD_MS) + expectState { copy(isDeleting = false, isCompleted = true) } + expectSideEffect(CardDeleteSideEffect.ShredSuccess) + } + + coVerify(exactly = 1) { deleteCard(CARD_ID) } + } + + @Test + fun `삭제에 실패하면 초기 상태로 돌아가 다시 시도할 수 있다`() = runTest { + coEvery { deleteCard(CARD_ID) } returns AppResult.Failure(IllegalStateException("실패")) + + CardDeleteViewModel(deleteCard).test(this) { + repeat(SHRED_TOTAL_TAPS) { containerHost.onShredTap(CARD_ID) } + repeat(SHRED_TOTAL_TAPS) { tapIndex -> expectState { copy(shredTapCount = tapIndex + 1) } } + expectState { copy(shredTapCount = SHRED_TOTAL_TAPS, isDeleting = true) } + advanceTimeBy(SHRED_COMPLETE_HOLD_MS) + expectState { CardDeleteState() } + expectSideEffect(CardDeleteSideEffect.ShredFailure) + + containerHost.onShredTap(CARD_ID) + expectState { copy(shredTapCount = 1) } + } + + coVerify(exactly = 1) { deleteCard(CARD_ID) } + } + + private companion object { + const val CARD_ID = 42L + } +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 8afc0cd3..114a7b82 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -72,6 +72,7 @@ import org.orbitmvi.orbit.compose.collectSideEffect fun ChatRoomScreen( conversationId: Long, onCardClose: () -> Unit, + onCardDeleteClick: (Long) -> Unit, modifier: Modifier = Modifier, viewModel: ChatRoomViewModel = hiltViewModel(), ) { @@ -116,7 +117,11 @@ fun ChatRoomScreen( onConfirm = viewModel::onEndConfirm, onDismiss = viewModel::onEndCancel, ) - is EndFlow.CardReady -> CardBottomSheet(card = endFlow.card, onDismiss = onCardClose) + is EndFlow.CardReady -> CardBottomSheet( + card = endFlow.card, + onDismiss = onCardClose, + onDeleteClick = { onCardDeleteClick(endFlow.card.id) }, + ) EndFlow.NotStarted, EndFlow.Ending, EndFlow.CreatingCard, @@ -155,6 +160,7 @@ private fun EndConversationDialog( private fun CardBottomSheet( card: Card, onDismiss: () -> Unit, + onDeleteClick: () -> Unit, ) { ModalBottomSheet(onDismissRequest = onDismiss) { Column( @@ -175,6 +181,9 @@ private fun CardBottomSheet( style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + TextButton(onClick = onDeleteClick) { + Text(text = stringResource(R.string.chat_room_card_delete_button)) + } } } } diff --git a/feature/chat/src/main/res/values/strings.xml b/feature/chat/src/main/res/values/strings.xml index 45001cf6..38d07225 100644 --- a/feature/chat/src/main/res/values/strings.xml +++ b/feature/chat/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ 감정 기록 생성 시 대화는 종료되며,\n더 이상 대화를 이어갈 수 없어요. 뒤로가기 감정 확인하기 + 카드 삭제 혼자 감당하지 않아도 괜찮아요. 대화에서 도움이 필요하다는 신호가 확인됐어요.\n전문가와 이야기해 보는 걸 추천드려요. 24시간 상담이 가능해요. 누르면 바로 연결돼요. 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 64323f47..fa464eef 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 @@ -208,8 +208,10 @@ 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, summary = summary, message = "대사")) } + + override suspend fun deleteCard(cardId: Long): AppResult = error("채팅방 테스트에서 쓰지 않는다") } internal fun message(id: Long, conversationId: Long, sender: MessageSender, content: String) = Message( 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 ca5c12e6..f5ab2d86 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 @@ -116,6 +116,8 @@ private object NoCardRepository : CardRepository { character: EmotionCharacter, summary: String, ): AppResult = error("홈 테스트에서 쓰지 않는다") + + override suspend fun deleteCard(cardId: Long): AppResult = error("홈 테스트에서 쓰지 않는다") } private object PassThroughSummarizer : DiarySummarizer { diff --git a/feature/setting/build.gradle.kts b/feature/setting/build.gradle.kts index 1f7ac65c..f479576b 100644 --- a/feature/setting/build.gradle.kts +++ b/feature/setting/build.gradle.kts @@ -11,4 +11,4 @@ dependencies { implementation(projects.core.ui) implementation(projects.domain) implementation(libs.compose.material.icons.core) -} \ No newline at end of file +} diff --git a/settings.gradle.kts b/settings.gradle.kts index f77cf8eb..bf857137 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,5 +37,6 @@ include(":feature:calendar") include(":feature:login") include(":feature:emotion") include(":feature:setting") +include(":feature:carddelete") include(":feature:webview") include(":core:designsystem") From 9a85f1be277f809b298c30fba45a5709d0b51a10 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:56:51 +0900 Subject: [PATCH 02/11] =?UTF-8?q?fix:=20=EC=B9=B4=EB=93=9C=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=88=84=EB=9D=BD=20=ED=95=84=EB=93=9C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gamss/android/app/main/MainScreen.kt | 4 +- .../core/designsystem/button/GamssButton.kt | 1 + .../card/model/response/CardResponse.kt | 33 ++++++++------ .../data/repository/CardRepositoryImpl.kt | 4 +- .../data/remote/card/CardResponseTest.kt | 43 +++++++++++++++++++ 5 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 data/src/test/kotlin/com/gamss/android/data/remote/card/CardResponseTest.kt 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 e8ae85ad..ca630c5c 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 @@ -28,6 +28,8 @@ import com.gamss.android.core.designsystem.component.GamssPaperBackground import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.feature.calendar.CalendarScreen import com.gamss.android.feature.calendar.navigation.CalendarKey +import com.gamss.android.feature.carddelete.CardDeleteScreen +import com.gamss.android.feature.carddelete.navigation.CardDeleteKey import com.gamss.android.feature.chat.ChatRoomScreen import com.gamss.android.feature.chat.ChattingListScreen import com.gamss.android.feature.chat.navigation.ChatKey @@ -35,8 +37,6 @@ import com.gamss.android.feature.chat.navigation.ChatRoomKey import com.gamss.android.feature.home.HomeScreen import com.gamss.android.feature.home.navigation.HomeKey import com.gamss.android.feature.setting.accountinfo.AccountInfoScreen -import com.gamss.android.feature.carddelete.CardDeleteScreen -import com.gamss.android.feature.carddelete.navigation.CardDeleteKey import com.gamss.android.feature.setting.main.SettingScreen import com.gamss.android.feature.setting.navigation.AccountInfoKey import com.gamss.android.feature.setting.navigation.NicknameChangeKey diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButton.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButton.kt index d67e0815..2a29d3d4 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButton.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButton.kt @@ -27,6 +27,7 @@ import com.gamss.android.core.designsystem.theme.GamssTheme * 버튼 너비는 [modifier]를 통해 화면에 맞게 조절할 수 있으며, 콘텐츠 주변에는 디자인 가이드의 * 최소 여백 16dp가 항상 적용됩니다. [enabled]가 `false`이면 [variant]와 관계없이 비활성 색상이 * 적용되고 클릭 이벤트도 전달되지 않습니다. + * [isProcessing]이 `true`이면 현재 색상은 유지하면서 중복 클릭만 막습니다. * * 누를 때 ripple을 그리지 않습니다. 디자인에 눌림 표현이 없어, 기본 indication을 두면 색이 * 겹쳐 보이는 잔상이 생깁니다. 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 03e5fd3c..d6456418 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 @@ -6,17 +6,26 @@ import kotlinx.serialization.Serializable @Serializable internal data class CardResponse( - val id: Long, - val conversationId: Long, - val emotion: String, - val summary: String, - val message: String, + val id: Long? = null, + val conversationId: Long? = null, + val emotion: String? = null, + val summary: String? = null, + val message: String? = null, ) -internal fun CardResponse.toDomain(): Card = Card( - id = id, - conversationId = conversationId, - character = checkNotNull(emotion.toEmotionCharacter()) { "Unknown card emotion: $emotion" }, - summary = summary, - message = message, -) +/** 서버가 필수 필드를 누락하거나 미지 감정을 보내면 잘못된 카드를 만들지 않는다. */ +internal fun CardResponse.toDomainOrNull(): Card? { + val cardId = id ?: return null + val conversationId = conversationId ?: return null + val character = emotion.toEmotionCharacter() ?: return null + val summary = summary ?: return null + val message = message ?: return null + + return Card( + id = cardId, + conversationId = conversationId, + character = character, + summary = summary, + message = message, + ) +} 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 9ccf21dd..ff2aa579 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 @@ -3,7 +3,7 @@ package com.gamss.android.data.repository 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 @@ -33,7 +33,7 @@ internal class CardRepositoryImpl @Inject constructor( ), ) response.throwIfFailed() - checkNotNull(response.data) { "No available card data" }.toDomain() + checkNotNull(response.data?.toDomainOrNull()) { "No available valid card data" } } return when (result) { is AppResult.Success -> result 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..be32b1d2 --- /dev/null +++ b/data/src/test/kotlin/com/gamss/android/data/remote/card/CardResponseTest.kt @@ -0,0 +1,43 @@ +package com.gamss.android.data.remote.card + +import com.gamss.android.data.remote.gamssJson +import com.gamss.android.data.remote.card.model.response.CardResponse +import com.gamss.android.data.remote.card.model.response.toDomainOrNull +import com.gamss.android.domain.emotion.EmotionCharacter +import kotlinx.serialization.decodeFromString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CardResponseTest { + + @Test + fun `명세의 추가 필드가 있어도 카드 응답을 읽는다`() { + val response = gamssJson.decodeFromString( + """{"id":1,"conversationId":2,"emotion":"ANGER","summary":"요약","message":"대사","emotionLabel":"분노","date":"2026-08-15"}""", + ) + + val card = response.toDomainOrNull() + + assertEquals(1L, card?.id) + assertEquals(EmotionCharacter.ANGER, card?.character) + } + + @Test + fun `필수 필드가 누락된 응답은 카드로 변환하지 않는다`() { + val response = gamssJson.decodeFromString( + """{"conversationId":2,"emotion":"ANGER","summary":"요약","message":"대사"}""", + ) + + assertNull(response.toDomainOrNull()) + } + + @Test + fun `알 수 없는 감정은 카드로 변환하지 않는다`() { + val response = gamssJson.decodeFromString( + """{"id":1,"conversationId":2,"emotion":"NEW_EMOTION","summary":"요약","message":"대사"}""", + ) + + assertNull(response.toDomainOrNull()) + } +} From 17ff0fea0ed94b1bee00276cc710f18ca028bc07 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:20:54 +0900 Subject: [PATCH 03/11] =?UTF-8?q?fix:=20detekt=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EC=A4=80=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../card/model/response/CardResponse.kt | 34 ++++++++------ .../data/remote/card/CardResponseTest.kt | 7 ++- .../domain/card/CreateCardUseCaseTest.kt | 10 ++++- .../domain/card/DeleteCardUseCaseTest.kt | 7 ++- .../conversation/ConversationSessionTest.kt | 10 ++++- .../carddelete/component/ShredStrips.kt | 44 +++++++++++++++---- .../android/feature/chat/ChatRoomTestFakes.kt | 10 ++++- 7 files changed, 92 insertions(+), 30 deletions(-) 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 d6456418..c9ad1088 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,7 +1,7 @@ package com.gamss.android.data.remote.card.model.response -import com.gamss.android.domain.card.Card import com.gamss.android.data.remote.emotion.toEmotionCharacter +import com.gamss.android.domain.card.Card import kotlinx.serialization.Serializable @Serializable @@ -15,17 +15,23 @@ internal data class CardResponse( /** 서버가 필수 필드를 누락하거나 미지 감정을 보내면 잘못된 카드를 만들지 않는다. */ internal fun CardResponse.toDomainOrNull(): Card? { - val cardId = id ?: return null - val conversationId = conversationId ?: return null - val character = emotion.toEmotionCharacter() ?: return null - val summary = summary ?: return null - val message = message ?: return null - - return Card( - id = cardId, - conversationId = conversationId, - character = character, - summary = summary, - message = message, - ) + return idsOrNull()?.let { (cardId, cardConversationId) -> + textOrNull()?.let { (cardSummary, cardMessage) -> + emotion.toEmotionCharacter()?.let { character -> + Card( + id = cardId, + conversationId = cardConversationId, + character = character, + summary = cardSummary, + message = cardMessage, + ) + } + } + } } + +private fun CardResponse.idsOrNull(): Pair? = + id?.let { cardId -> conversationId?.let { cardId to it } } + +private fun CardResponse.textOrNull(): Pair? = + summary?.let { cardSummary -> message?.let { cardSummary to it } } 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 index be32b1d2..6c2d622a 100644 --- 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 @@ -1,8 +1,8 @@ package com.gamss.android.data.remote.card -import com.gamss.android.data.remote.gamssJson import com.gamss.android.data.remote.card.model.response.CardResponse import com.gamss.android.data.remote.card.model.response.toDomainOrNull +import com.gamss.android.data.remote.gamssJson import com.gamss.android.domain.emotion.EmotionCharacter import kotlinx.serialization.decodeFromString import org.junit.Assert.assertEquals @@ -14,7 +14,10 @@ class CardResponseTest { @Test fun `명세의 추가 필드가 있어도 카드 응답을 읽는다`() { val response = gamssJson.decodeFromString( - """{"id":1,"conversationId":2,"emotion":"ANGER","summary":"요약","message":"대사","emotionLabel":"분노","date":"2026-08-15"}""", + """ + {"id":1,"conversationId":2,"emotion":"ANGER","summary":"요약","message":"대사", + "emotionLabel":"분노","date":"2026-08-15"} + """.trimIndent(), ) val card = response.toDomainOrNull() 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 272ea41f..ee40cea7 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 @@ -19,7 +19,15 @@ class CreateCardUseCaseTest { summary: String, ): AppResult { sentSummary = summary - return AppResult.Success(Card(id = 1L, conversationId = conversationId, character = character, summary = summary, message = "대사")) + return AppResult.Success( + Card( + id = 1L, + conversationId = conversationId, + character = character, + summary = summary, + message = "대사", + ), + ) } override suspend fun deleteCard(cardId: Long): AppResult = AppResult.Success(Unit) 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 index 074b195c..1581257e 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardUseCaseTest.kt @@ -11,8 +11,11 @@ class DeleteCardUseCaseTest { private class RecordingRepository : CardRepository { var deletedCardId: Long? = null - override suspend fun createCard(conversationId: Long, character: EmotionCharacter, summary: String): AppResult = - error("사용하지 않음") + override suspend fun createCard( + conversationId: Long, + character: EmotionCharacter, + summary: String, + ): AppResult = error("사용하지 않음") override suspend fun deleteCard(cardId: Long): AppResult { deletedCardId = cardId 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 0fc1145c..34b078e8 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 @@ -218,7 +218,15 @@ class ConversationSessionTest { conversationId: Long, character: EmotionCharacter, summary: String, - ): AppResult = AppResult.Success(Card(id = 1L, conversationId = conversationId, character = character, summary = summary, message = "대사")) + ): AppResult = AppResult.Success( + Card( + id = 1L, + conversationId = conversationId, + character = character, + summary = summary, + message = "대사", + ), + ) override suspend fun deleteCard(cardId: Long): AppResult = AppResult.Success(Unit) } diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStrips.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStrips.kt index 4d0b79dc..d3495401 100644 --- a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStrips.kt +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStrips.kt @@ -21,17 +21,23 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.gamss.android.core.designsystem.R as DesignSystemR import com.gamss.android.feature.carddelete.SHRED_TAP_STEP_DURATION_MS import com.gamss.android.feature.carddelete.SHRED_TEAR_FRACTION +import com.gamss.android.core.designsystem.R as DesignSystemR @Composable fun ShredStrips(progress: Float, modifier: Modifier = Modifier) { val displayProgress = remember { Animatable(0f) } LaunchedEffect(progress) { - if (progress < displayProgress.value) displayProgress.snapTo(progress) - else displayProgress.animateTo(progress, tween(SHRED_TAP_STEP_DURATION_MS, easing = FastOutSlowInEasing)) + if (progress < displayProgress.value) { + displayProgress.snapTo(progress) + } else { + displayProgress.animateTo( + progress, + tween(SHRED_TAP_STEP_DURATION_MS, easing = FastOutSlowInEasing), + ) + } } BoxWithConstraints(modifier = modifier.clipToBounds()) { @@ -42,14 +48,19 @@ fun ShredStrips(progress: Float, modifier: Modifier = Modifier) { .coerceIn(0f, 1f) Row( - modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter).padding(horizontal = StripRowHorizontalPadding), + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .padding(horizontal = StripRowHorizontalPadding), horizontalArrangement = Arrangement.spacedBy(StripGap), ) { LaneSpecs.forEach { lane -> ShredStrip( modifier = Modifier.weight(1f), heightFraction = tearProgress, - fallFraction = ((fallProgress - lane.fallStartOffset) / (1f - lane.fallStartOffset)).coerceIn(0f, 1f), + fallFraction = ( + (fallProgress - lane.fallStartOffset) / (1f - lane.fallStartOffset) + ).coerceIn(0f, 1f), fallDistance = fallDistance, targetHeight = stripMaxHeight * lane.targetHeightRatio, ) @@ -59,20 +70,35 @@ fun ShredStrips(progress: Float, modifier: Modifier = Modifier) { } @Composable -private fun ShredStrip(heightFraction: Float, fallFraction: Float, fallDistance: Dp, targetHeight: Dp, modifier: Modifier) { +private fun ShredStrip( + heightFraction: Float, + fallFraction: Float, + fallDistance: Dp, + targetHeight: Dp, + modifier: Modifier, +) { val currentHeight = StripBaseHeight + (targetHeight - StripBaseHeight) * heightFraction Image( painter = painterResource(DesignSystemR.drawable.img_paper_strip), contentDescription = null, contentScale = ContentScale.FillBounds, - modifier = modifier.height(currentHeight).graphicsLayer { translationY = fallDistance.toPx() * fallFraction }, + modifier = modifier + .height(currentHeight) + .graphicsLayer { translationY = fallDistance.toPx() * fallFraction }, ) } private data class LaneSpec(val targetHeightRatio: Float, val fallStartOffset: Float) + private val LaneSpecs = listOf( - LaneSpec(0.55f, 0.00f), LaneSpec(0.71f, 0.05f), LaneSpec(0.48f, 0.02f), LaneSpec(0.90f, 0.08f), - LaneSpec(0.71f, 0.01f), LaneSpec(0.55f, 0.06f), LaneSpec(1.00f, 0.00f), LaneSpec(0.80f, 0.07f), + LaneSpec(targetHeightRatio = 0.55f, fallStartOffset = 0.00f), + LaneSpec(targetHeightRatio = 0.71f, fallStartOffset = 0.05f), + LaneSpec(targetHeightRatio = 0.48f, fallStartOffset = 0.02f), + LaneSpec(targetHeightRatio = 0.90f, fallStartOffset = 0.08f), + LaneSpec(targetHeightRatio = 0.71f, fallStartOffset = 0.01f), + LaneSpec(targetHeightRatio = 0.55f, fallStartOffset = 0.06f), + LaneSpec(targetHeightRatio = 1.00f, fallStartOffset = 0.00f), + LaneSpec(targetHeightRatio = 0.80f, fallStartOffset = 0.07f), ) private val StripBaseHeight = 28.dp private const val STRIP_MAX_HEIGHT_FRACTION = 0.55f 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 fa464eef..7567a2a4 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 @@ -208,7 +208,15 @@ internal class CountingCardRepository( calls++ gate?.await() return failure?.let { AppResult.Failure(it) } - ?: AppResult.Success(Card(id = 1L, conversationId = conversationId, character = character, summary = summary, message = "대사")) + ?: AppResult.Success( + Card( + id = 1L, + conversationId = conversationId, + character = character, + summary = summary, + message = "대사", + ), + ) } override suspend fun deleteCard(cardId: Long): AppResult = error("채팅방 테스트에서 쓰지 않는다") From c94fb214e9080f2d34b0f53558e6ed61e3bc4ecf Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:29:25 +0900 Subject: [PATCH 04/11] =?UTF-8?q?fix:=20debug=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=8C=80=ED=99=94=20=EC=A2=85=EB=A3=8C=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/gamss/android/app/main/MainScreen.kt | 2 ++ .../com/gamss/android/feature/chat/ChatRoomScreen.kt | 12 ++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) 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 ca630c5c..840941a8 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 @@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.Color import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay +import com.gamss.android.app.BuildConfig import com.gamss.android.app.navigation.Navigator import com.gamss.android.app.navigation.bottomBarItems import com.gamss.android.app.navigation.keys @@ -143,6 +144,7 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { conversationId = key.conversationId, onCardClose = navigator::goBack, onCardDeleteClick = { cardId -> navigator.navigate(CardDeleteKey(cardId)) }, + showEndButtonInDebug = BuildConfig.BUILD_TYPE == "debug", ) } } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 114a7b82..4a9aba87 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -67,12 +67,14 @@ import org.orbitmvi.orbit.compose.collectSideEffect /** * @param onCardClose 카드 시트를 닫을 때 호출한다. 이 화면을 실제로 벗어나야 한다. * 머무르면 카드 단계가 그대로라 시트가 다시 열린다. + * @param showEndButtonInDebug 원격 플래그와 무관하게 debug 빌드에서 종료 흐름을 검증한다. */ @Composable fun ChatRoomScreen( conversationId: Long, onCardClose: () -> Unit, onCardDeleteClick: (Long) -> Unit, + showEndButtonInDebug: Boolean, modifier: Modifier = Modifier, viewModel: ChatRoomViewModel = hiltViewModel(), ) { @@ -98,7 +100,12 @@ fun ChatRoomScreen( ) } - ChatRoomContent(state = state, actions = actions, modifier = modifier) + ChatRoomContent( + state = state, + actions = actions, + showEndButtonInDebug = showEndButtonInDebug, + modifier = modifier, + ) state.riskDetection?.let { detection -> SupportAgencyDialog( @@ -201,6 +208,7 @@ private data class ChatRoomActions( private fun ChatRoomContent( state: ChatRoomState, actions: ChatRoomActions, + showEndButtonInDebug: Boolean, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() @@ -219,7 +227,7 @@ private fun ChatRoomContent( ChatRoomTopBar( endFlow = state.endFlow, canEnd = state.canEnd, - showEndButton = state.useChatEndFeature, + showEndButton = state.useChatEndFeature || showEndButtonInDebug, onEndClick = actions.onEndClick, ) }, From 3f75ccdf4bc65025fbfe7d8fc20ba204402f0eeb Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:32:42 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20debug=20=ED=8C=8C=EC=87=84=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/gamss/android/app/main/MainScreen.kt | 6 ++++++ .../android/feature/carddelete/CardDeleteScreen.kt | 3 ++- .../feature/carddelete/CardDeleteViewModel.kt | 7 ++++++- .../feature/carddelete/navigation/CardDeleteKey.kt | 5 ++++- .../feature/carddelete/CardDeleteViewModelTest.kt | 13 +++++++++++++ .../android/feature/setting/main/SettingScreen.kt | 7 +++++++ feature/setting/src/main/res/values/strings.xml | 1 + 7 files changed, 39 insertions(+), 3 deletions(-) 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 840941a8..4c41dd03 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 @@ -109,11 +109,17 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { onAccountInfoClick = { navigator.navigate(AccountInfoKey) }, onServiceTermsClick = { navigator.navigate(WebViewKey(GamssWebPage.ServiceTerms)) }, onPrivacyPolicyClick = { navigator.navigate(WebViewKey(GamssWebPage.PrivacyPolicy)) }, + onCardDeletePreviewClick = if (BuildConfig.BUILD_TYPE == "debug") { + { navigator.navigate(CardDeleteKey(cardId = 0L, isAnimationPreview = true)) } + } else { + null + }, ) } entry { key -> CardDeleteScreen( cardId = key.cardId, + isAnimationPreview = key.isAnimationPreview, onBackClick = navigator::goBack, onDeleteComplete = navigator::finishCurrentFlow, ) diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt index aa4509bb..0f05e824 100644 --- a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt @@ -30,6 +30,7 @@ import org.orbitmvi.orbit.compose.collectSideEffect @Composable fun CardDeleteScreen( cardId: Long, + isAnimationPreview: Boolean, onBackClick: () -> Unit, onDeleteComplete: () -> Unit, modifier: Modifier = Modifier, @@ -54,7 +55,7 @@ fun CardDeleteScreen( else -> onBackClick }, onDeleteComplete = onDeleteComplete, - onShredTap = { viewModel.onShredTap(cardId) }, + onShredTap = { viewModel.onShredTap(cardId, isAnimationPreview) }, modifier = modifier, ) } diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt index 41669607..b3574c3e 100644 --- a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt @@ -19,7 +19,7 @@ class CardDeleteViewModel @Inject constructor( override val container = container(CardDeleteState()) /** 누를 때마다 종이가 한 단계씩 내려간다. 끝까지 내려가면 전달받은 카드만 삭제한다. */ - fun onShredTap(cardId: Long) = intent { + fun onShredTap(cardId: Long, isAnimationPreview: Boolean = false) = intent { if (state.isDeleting || state.shredTapCount >= SHRED_TOTAL_TAPS) return@intent val nextTapCount = state.shredTapCount + 1 @@ -27,6 +27,11 @@ class CardDeleteViewModel @Inject constructor( if (nextTapCount < SHRED_TOTAL_TAPS) return@intent reduce { state.copy(isDeleting = true) } + if (isAnimationPreview) { + delay(SHRED_COMPLETE_HOLD_MS) + reduce { state.copy(isDeleting = false, isCompleted = true) } + return@intent + } val result = coroutineScope { val minimumHold = launch { delay(SHRED_COMPLETE_HOLD_MS) } val outcome = deleteCard(cardId) diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt index 64162152..d9970edb 100644 --- a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt @@ -5,4 +5,7 @@ import kotlinx.serialization.Serializable /** 삭제 대상은 화면 밖에서 선택하고, 이 키에는 안정적인 식별자만 전달한다. */ @Serializable -data class CardDeleteKey(val cardId: Long) : NavKey +data class CardDeleteKey( + val cardId: Long, + val isAnimationPreview: Boolean = false, +) : NavKey diff --git a/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt b/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt index 47002274..77559f4e 100644 --- a/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt +++ b/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt @@ -56,6 +56,19 @@ class CardDeleteViewModelTest { coVerify(exactly = 1) { deleteCard(CARD_ID) } } + @Test + fun `애니메이션 미리보기는 삭제 요청 없이 완료 상태를 보여준다`() = runTest { + CardDeleteViewModel(deleteCard).test(this) { + repeat(SHRED_TOTAL_TAPS) { containerHost.onShredTap(CARD_ID, isAnimationPreview = true) } + repeat(SHRED_TOTAL_TAPS) { tapIndex -> expectState { copy(shredTapCount = tapIndex + 1) } } + expectState { copy(shredTapCount = SHRED_TOTAL_TAPS, isDeleting = true) } + advanceTimeBy(SHRED_COMPLETE_HOLD_MS) + expectState { copy(isDeleting = false, isCompleted = true) } + } + + coVerify(exactly = 0) { deleteCard(any()) } + } + private companion object { const val CARD_ID = 42L } diff --git a/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt b/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt index 297a95e9..6e3b2321 100644 --- a/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt +++ b/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt @@ -25,6 +25,7 @@ fun SettingScreen( onAccountInfoClick: () -> Unit, onServiceTermsClick: () -> Unit, onPrivacyPolicyClick: () -> Unit, + onCardDeletePreviewClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -64,6 +65,12 @@ fun SettingScreen( title = stringResource(R.string.setting_list_app_version), additionalInfo = appVersion, ) + if (onCardDeletePreviewClick != null) { + SettingListItem( + title = stringResource(R.string.setting_list_card_delete_preview), + onClick = onCardDeletePreviewClick, + ) + } } } } diff --git a/feature/setting/src/main/res/values/strings.xml b/feature/setting/src/main/res/values/strings.xml index 4b9ec673..28124689 100644 --- a/feature/setting/src/main/res/values/strings.xml +++ b/feature/setting/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ 서비스 이용약관 개인정보 처리방침 앱 버전 + 카드 파쇄 애니메이션 닉네임 변경 이메일 로그아웃 From 9b2d87fbbb0cb7aab0573b35d70a25d883a69b0d Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:46:10 +0900 Subject: [PATCH 06/11] =?UTF-8?q?Revert=20"feat:=20debug=20=ED=8C=8C?= =?UTF-8?q?=EC=87=84=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=B6=94=EA=B0=80"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 3f75ccdf4bc65025fbfe7d8fc20ba204402f0eeb. --- .../kotlin/com/gamss/android/app/main/MainScreen.kt | 6 ------ .../android/feature/carddelete/CardDeleteScreen.kt | 3 +-- .../feature/carddelete/CardDeleteViewModel.kt | 7 +------ .../feature/carddelete/navigation/CardDeleteKey.kt | 5 +---- .../feature/carddelete/CardDeleteViewModelTest.kt | 13 ------------- .../android/feature/setting/main/SettingScreen.kt | 7 ------- feature/setting/src/main/res/values/strings.xml | 1 - 7 files changed, 3 insertions(+), 39 deletions(-) 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 4c41dd03..840941a8 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 @@ -109,17 +109,11 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { onAccountInfoClick = { navigator.navigate(AccountInfoKey) }, onServiceTermsClick = { navigator.navigate(WebViewKey(GamssWebPage.ServiceTerms)) }, onPrivacyPolicyClick = { navigator.navigate(WebViewKey(GamssWebPage.PrivacyPolicy)) }, - onCardDeletePreviewClick = if (BuildConfig.BUILD_TYPE == "debug") { - { navigator.navigate(CardDeleteKey(cardId = 0L, isAnimationPreview = true)) } - } else { - null - }, ) } entry { key -> CardDeleteScreen( cardId = key.cardId, - isAnimationPreview = key.isAnimationPreview, onBackClick = navigator::goBack, onDeleteComplete = navigator::finishCurrentFlow, ) diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt index 0f05e824..aa4509bb 100644 --- a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt @@ -30,7 +30,6 @@ import org.orbitmvi.orbit.compose.collectSideEffect @Composable fun CardDeleteScreen( cardId: Long, - isAnimationPreview: Boolean, onBackClick: () -> Unit, onDeleteComplete: () -> Unit, modifier: Modifier = Modifier, @@ -55,7 +54,7 @@ fun CardDeleteScreen( else -> onBackClick }, onDeleteComplete = onDeleteComplete, - onShredTap = { viewModel.onShredTap(cardId, isAnimationPreview) }, + onShredTap = { viewModel.onShredTap(cardId) }, modifier = modifier, ) } diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt index b3574c3e..41669607 100644 --- a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt @@ -19,7 +19,7 @@ class CardDeleteViewModel @Inject constructor( override val container = container(CardDeleteState()) /** 누를 때마다 종이가 한 단계씩 내려간다. 끝까지 내려가면 전달받은 카드만 삭제한다. */ - fun onShredTap(cardId: Long, isAnimationPreview: Boolean = false) = intent { + fun onShredTap(cardId: Long) = intent { if (state.isDeleting || state.shredTapCount >= SHRED_TOTAL_TAPS) return@intent val nextTapCount = state.shredTapCount + 1 @@ -27,11 +27,6 @@ class CardDeleteViewModel @Inject constructor( if (nextTapCount < SHRED_TOTAL_TAPS) return@intent reduce { state.copy(isDeleting = true) } - if (isAnimationPreview) { - delay(SHRED_COMPLETE_HOLD_MS) - reduce { state.copy(isDeleting = false, isCompleted = true) } - return@intent - } val result = coroutineScope { val minimumHold = launch { delay(SHRED_COMPLETE_HOLD_MS) } val outcome = deleteCard(cardId) diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt index d9970edb..64162152 100644 --- a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt @@ -5,7 +5,4 @@ import kotlinx.serialization.Serializable /** 삭제 대상은 화면 밖에서 선택하고, 이 키에는 안정적인 식별자만 전달한다. */ @Serializable -data class CardDeleteKey( - val cardId: Long, - val isAnimationPreview: Boolean = false, -) : NavKey +data class CardDeleteKey(val cardId: Long) : NavKey diff --git a/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt b/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt index 77559f4e..47002274 100644 --- a/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt +++ b/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt @@ -56,19 +56,6 @@ class CardDeleteViewModelTest { coVerify(exactly = 1) { deleteCard(CARD_ID) } } - @Test - fun `애니메이션 미리보기는 삭제 요청 없이 완료 상태를 보여준다`() = runTest { - CardDeleteViewModel(deleteCard).test(this) { - repeat(SHRED_TOTAL_TAPS) { containerHost.onShredTap(CARD_ID, isAnimationPreview = true) } - repeat(SHRED_TOTAL_TAPS) { tapIndex -> expectState { copy(shredTapCount = tapIndex + 1) } } - expectState { copy(shredTapCount = SHRED_TOTAL_TAPS, isDeleting = true) } - advanceTimeBy(SHRED_COMPLETE_HOLD_MS) - expectState { copy(isDeleting = false, isCompleted = true) } - } - - coVerify(exactly = 0) { deleteCard(any()) } - } - private companion object { const val CARD_ID = 42L } diff --git a/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt b/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt index 6e3b2321..297a95e9 100644 --- a/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt +++ b/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt @@ -25,7 +25,6 @@ fun SettingScreen( onAccountInfoClick: () -> Unit, onServiceTermsClick: () -> Unit, onPrivacyPolicyClick: () -> Unit, - onCardDeletePreviewClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -65,12 +64,6 @@ fun SettingScreen( title = stringResource(R.string.setting_list_app_version), additionalInfo = appVersion, ) - if (onCardDeletePreviewClick != null) { - SettingListItem( - title = stringResource(R.string.setting_list_card_delete_preview), - onClick = onCardDeletePreviewClick, - ) - } } } } diff --git a/feature/setting/src/main/res/values/strings.xml b/feature/setting/src/main/res/values/strings.xml index 28124689..4b9ec673 100644 --- a/feature/setting/src/main/res/values/strings.xml +++ b/feature/setting/src/main/res/values/strings.xml @@ -5,7 +5,6 @@ 서비스 이용약관 개인정보 처리방침 앱 버전 - 카드 파쇄 애니메이션 닉네임 변경 이메일 로그아웃 From 6dfe37912025aaac4ab3fa2f878fa52004639466 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:57:25 +0900 Subject: [PATCH 07/11] =?UTF-8?q?Revert=20"fix:=20debug=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=8C=80=ED=99=94=20=EC=A2=85=EB=A3=8C=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=20=EB=85=B8=EC=B6=9C"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit c94fb214e9080f2d34b0f53558e6ed61e3bc4ecf. --- .../kotlin/com/gamss/android/app/main/MainScreen.kt | 2 -- .../com/gamss/android/feature/chat/ChatRoomScreen.kt | 12 ++---------- 2 files changed, 2 insertions(+), 12 deletions(-) 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 840941a8..ca630c5c 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 @@ -16,7 +16,6 @@ import androidx.compose.ui.graphics.Color import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay -import com.gamss.android.app.BuildConfig import com.gamss.android.app.navigation.Navigator import com.gamss.android.app.navigation.bottomBarItems import com.gamss.android.app.navigation.keys @@ -144,7 +143,6 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { conversationId = key.conversationId, onCardClose = navigator::goBack, onCardDeleteClick = { cardId -> navigator.navigate(CardDeleteKey(cardId)) }, - showEndButtonInDebug = BuildConfig.BUILD_TYPE == "debug", ) } } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 4a9aba87..114a7b82 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -67,14 +67,12 @@ import org.orbitmvi.orbit.compose.collectSideEffect /** * @param onCardClose 카드 시트를 닫을 때 호출한다. 이 화면을 실제로 벗어나야 한다. * 머무르면 카드 단계가 그대로라 시트가 다시 열린다. - * @param showEndButtonInDebug 원격 플래그와 무관하게 debug 빌드에서 종료 흐름을 검증한다. */ @Composable fun ChatRoomScreen( conversationId: Long, onCardClose: () -> Unit, onCardDeleteClick: (Long) -> Unit, - showEndButtonInDebug: Boolean, modifier: Modifier = Modifier, viewModel: ChatRoomViewModel = hiltViewModel(), ) { @@ -100,12 +98,7 @@ fun ChatRoomScreen( ) } - ChatRoomContent( - state = state, - actions = actions, - showEndButtonInDebug = showEndButtonInDebug, - modifier = modifier, - ) + ChatRoomContent(state = state, actions = actions, modifier = modifier) state.riskDetection?.let { detection -> SupportAgencyDialog( @@ -208,7 +201,6 @@ private data class ChatRoomActions( private fun ChatRoomContent( state: ChatRoomState, actions: ChatRoomActions, - showEndButtonInDebug: Boolean, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() @@ -227,7 +219,7 @@ private fun ChatRoomContent( ChatRoomTopBar( endFlow = state.endFlow, canEnd = state.canEnd, - showEndButton = state.useChatEndFeature || showEndButtonInDebug, + showEndButton = state.useChatEndFeature, onEndClick = actions.onEndClick, ) }, From 8904404da17787afaf231867aebc3d42952a9284 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:42:34 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20=EC=B9=B4=EB=93=9C=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=82=AD=EC=A0=9C=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gamss/android/app/main/MainScreen.kt | 10 +++++-- .../android/data/remote/card/CardService.kt | 8 ++--- .../card/model/response/CardDeleteResponse.kt | 8 +++++ .../data/repository/CardRepositoryImpl.kt | 15 ++++------ .../data/repository/CardRepositoryImplTest.kt | 29 ++++++++---------- .../android/domain/card/CardRepository.kt | 4 +-- .../domain/card/DeleteAllCardsUseCase.kt | 11 +++++++ .../android/domain/card/DeleteCardUseCase.kt | 13 -------- .../domain/card/CreateCardUseCaseTest.kt | 2 +- ...seTest.kt => DeleteAllCardsUseCaseTest.kt} | 18 +++++------ .../conversation/ConversationSessionTest.kt | 2 +- .../feature/carddelete/CardDeleteScreen.kt | 3 +- .../feature/carddelete/CardDeleteViewModel.kt | 10 +++---- .../carddelete/navigation/CardDeleteKey.kt | 3 +- .../carddelete/CardDeleteViewModelTest.kt | 30 ++++++++----------- .../android/feature/chat/ChatRoomScreen.kt | 6 ---- feature/chat/src/main/res/values/strings.xml | 1 - .../android/feature/chat/ChatRoomTestFakes.kt | 2 +- .../android/feature/home/HomeTestFakes.kt | 2 +- .../feature/setting/main/SettingScreen.kt | 7 +++++ .../setting/src/main/res/values/strings.xml | 1 + 21 files changed, 89 insertions(+), 96 deletions(-) create mode 100644 data/src/main/java/com/gamss/android/data/remote/card/model/response/CardDeleteResponse.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/card/DeleteAllCardsUseCase.kt delete mode 100644 domain/src/main/kotlin/com/gamss/android/domain/card/DeleteCardUseCase.kt rename domain/src/test/kotlin/com/gamss/android/domain/card/{DeleteCardUseCaseTest.kt => DeleteAllCardsUseCaseTest.kt} (60%) 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 ca630c5c..fe607cdf 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 @@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.Color import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay +import com.gamss.android.app.BuildConfig import com.gamss.android.app.navigation.Navigator import com.gamss.android.app.navigation.bottomBarItems import com.gamss.android.app.navigation.keys @@ -108,11 +109,15 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { onAccountInfoClick = { navigator.navigate(AccountInfoKey) }, onServiceTermsClick = { navigator.navigate(WebViewKey(GamssWebPage.ServiceTerms)) }, onPrivacyPolicyClick = { navigator.navigate(WebViewKey(GamssWebPage.PrivacyPolicy)) }, + onCardDeleteClick = if (BuildConfig.BUILD_TYPE == "debug") { + { navigator.navigate(CardDeleteKey) } + } else { + null + }, ) } - entry { key -> + entry { CardDeleteScreen( - cardId = key.cardId, onBackClick = navigator::goBack, onDeleteComplete = navigator::finishCurrentFlow, ) @@ -142,7 +147,6 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { ChatRoomScreen( conversationId = key.conversationId, onCardClose = navigator::goBack, - onCardDeleteClick = { cardId -> navigator.navigate(CardDeleteKey(cardId)) }, ) } } 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 29b5bfee..e8c659a3 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 @@ -1,12 +1,12 @@ package com.gamss.android.data.remote.card import com.gamss.android.data.remote.card.model.request.CreateCardRequest +import com.gamss.android.data.remote.card.model.response.CardDeleteResponse 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.POST -import retrofit2.http.Path internal interface CardService { @@ -14,7 +14,7 @@ internal interface CardService { @POST("/api/cards") suspend fun createCard(@Body request: CreateCardRequest): ApiResponse - /** 해당 카드와 카드가 나온 채팅방을 함께 삭제한다. */ - @DELETE("/api/cards/{cardId}") - suspend fun deleteCard(@Path("cardId") cardId: Long): ApiResponse + /** 모든 카드와 카드가 나온 채팅방을 함께 삭제한다. */ + @DELETE("/api/cards") + suspend fun deleteAllCards(): ApiResponse } diff --git a/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardDeleteResponse.kt b/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardDeleteResponse.kt new file mode 100644 index 00000000..85f223d6 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardDeleteResponse.kt @@ -0,0 +1,8 @@ +package com.gamss.android.data.remote.card.model.response + +import kotlinx.serialization.Serializable + +@Serializable +internal data class CardDeleteResponse( + val deletedCount: Int? = null, +) 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 ff2aa579..327f421e 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 @@ -47,15 +47,10 @@ 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 - } + override suspend fun deleteAllCards(): AppResult = runCatchingApiCall { + val response = cardService.deleteAllCards() + response.throwIfFailed() + checkNotNull(response.data?.deletedCount) { "No deleted card count" } + Unit } } 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 index 68ba558e..62a4d8af 100644 --- a/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt +++ b/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt @@ -3,6 +3,7 @@ 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.card.model.response.CardDeleteResponse import com.gamss.android.data.remote.model.response.ApiError import com.gamss.android.data.remote.model.response.ApiResponse import com.gamss.android.domain.auth.SessionExpiredException @@ -19,22 +20,23 @@ class CardRepositoryImplTest { private val repository = CardRepositoryImpl(cardService) @Test - fun `카드 ID로 삭제 요청을 전달한다`() = runTest { - coEvery { cardService.deleteCard(CARD_ID) } returns ApiResponse( + fun `모든 카드 삭제 요청을 전달한다`() = runTest { + coEvery { cardService.deleteAllCards() } returns ApiResponse( success = true, + data = CardDeleteResponse(deletedCount = 0), ) - assertEquals(AppResult.Success(Unit), repository.deleteCard(CARD_ID)) + assertEquals(AppResult.Success(Unit), repository.deleteAllCards()) } @Test fun `200이어도 실패 envelope면 실패로 전달한다`() = runTest { - coEvery { cardService.deleteCard(CARD_ID) } returns ApiResponse( + coEvery { cardService.deleteAllCards() } returns ApiResponse( success = false, error = ApiError(code = "CARD_DELETE_FAILED", message = "삭제 실패"), ) - val result = repository.deleteCard(CARD_ID) + val result = repository.deleteAllCards() assertEquals( "CARD_DELETE_FAILED", @@ -44,27 +46,22 @@ class CardRepositoryImplTest { @Test fun `실패 envelope의 인증 만료는 세션 만료로 전달한다`() = runTest { - coEvery { cardService.deleteCard(CARD_ID) } returns ApiResponse( + coEvery { cardService.deleteAllCards() } returns ApiResponse( success = false, error = ApiError(code = "EXPIRED_TOKEN", message = "만료"), ) - val result = repository.deleteCard(CARD_ID) + val result = repository.deleteAllCards() assertTrue((result as AppResult.Failure).throwable is SessionExpiredException) } @Test - fun `이미 삭제된 카드 응답은 삭제 완료로 처리한다`() = runTest { - coEvery { cardService.deleteCard(CARD_ID) } returns ApiResponse( - success = false, - error = ApiError(code = "CARD_ALREADY_DELETED", message = "이미 삭제됨"), - ) + fun `성공 응답에 삭제 수가 없으면 실패로 전달한다`() = runTest { + coEvery { cardService.deleteAllCards() } returns ApiResponse(success = true) - assertEquals(AppResult.Success(Unit), repository.deleteCard(CARD_ID)) - } + val result = repository.deleteAllCards() - private companion object { - const val CARD_ID = 42L + assertTrue(result is AppResult.Failure) } } 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 4a7b4f43..3e55c4a9 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 @@ -12,6 +12,6 @@ interface CardRepository { summary: String, ): AppResult - /** 해당 카드와 카드가 나온 채팅방을 함께 삭제한다. 되돌릴 수 없다. */ - suspend fun deleteCard(cardId: Long): AppResult + /** 모든 카드와 카드가 나온 채팅방을 함께 삭제한다. 되돌릴 수 없다. */ + suspend fun deleteAllCards(): AppResult } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/DeleteAllCardsUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/DeleteAllCardsUseCase.kt new file mode 100644 index 00000000..30b08d7a --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/card/DeleteAllCardsUseCase.kt @@ -0,0 +1,11 @@ +package com.gamss.android.domain.card + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.usecase.NoParamUseCase +import javax.inject.Inject + +class DeleteAllCardsUseCase @Inject constructor( + private val cardRepository: CardRepository, +) : NoParamUseCase> { + override suspend fun invoke(): AppResult = cardRepository.deleteAllCards() +} 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 deleted file mode 100644 index 252cd747..00000000 --- a/domain/src/main/kotlin/com/gamss/android/domain/card/DeleteCardUseCase.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.gamss.android.domain.card - -import com.gamss.android.core.common.AppResult -import com.gamss.android.domain.usecase.UseCase -import javax.inject.Inject - -/** 카드 ID로 한 장을 삭제한다. 카드가 나온 채팅방도 함께 삭제되며 되돌릴 수 없다. */ -class DeleteCardUseCase @Inject constructor( - private val cardRepository: CardRepository, -) : UseCase> { - - override suspend fun invoke(params: Long): AppResult = cardRepository.deleteCard(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 ee40cea7..3eb7a48a 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 @@ -30,7 +30,7 @@ class CreateCardUseCaseTest { ) } - override suspend fun deleteCard(cardId: Long): AppResult = AppResult.Success(Unit) + override suspend fun deleteAllCards(): AppResult = AppResult.Success(Unit) } @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/DeleteAllCardsUseCaseTest.kt similarity index 60% rename from domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardUseCaseTest.kt rename to domain/src/test/kotlin/com/gamss/android/domain/card/DeleteAllCardsUseCaseTest.kt index 1581257e..1c1942af 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteAllCardsUseCaseTest.kt @@ -6,10 +6,10 @@ import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test -class DeleteCardUseCaseTest { +class DeleteAllCardsUseCaseTest { private class RecordingRepository : CardRepository { - var deletedCardId: Long? = null + var deleteAllCalled = false override suspend fun createCard( conversationId: Long, @@ -17,23 +17,19 @@ class DeleteCardUseCaseTest { summary: String, ): AppResult = error("사용하지 않음") - override suspend fun deleteCard(cardId: Long): AppResult { - deletedCardId = cardId + override suspend fun deleteAllCards(): AppResult { + deleteAllCalled = true return AppResult.Success(Unit) } } @Test - fun 카드_ID를_리포지토리에_그대로_위임한다() = runBlocking { + fun 모든_카드_삭제를_리포지토리에_위임한다() = runBlocking { val repository = RecordingRepository() - val result = DeleteCardUseCase(repository)(CARD_ID) + val result = DeleteAllCardsUseCase(repository)() - assertEquals(CARD_ID, repository.deletedCardId) + assertEquals(true, repository.deleteAllCalled) assertEquals(AppResult.Success(Unit), result) } - - private companion object { - const val CARD_ID = 42L - } } 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 34b078e8..b6b4be8b 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 @@ -228,7 +228,7 @@ class ConversationSessionTest { ), ) - override suspend fun deleteCard(cardId: Long): AppResult = AppResult.Success(Unit) + override suspend fun deleteAllCards(): AppResult = AppResult.Success(Unit) } private class FakeConversationRepository( diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt index aa4509bb..b27e0cba 100644 --- a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt @@ -29,7 +29,6 @@ import org.orbitmvi.orbit.compose.collectSideEffect @Composable fun CardDeleteScreen( - cardId: Long, onBackClick: () -> Unit, onDeleteComplete: () -> Unit, modifier: Modifier = Modifier, @@ -54,7 +53,7 @@ fun CardDeleteScreen( else -> onBackClick }, onDeleteComplete = onDeleteComplete, - onShredTap = { viewModel.onShredTap(cardId) }, + onShredTap = viewModel::onShredTap, modifier = modifier, ) } diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt index 41669607..7558ff0d 100644 --- a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteViewModel.kt @@ -2,7 +2,7 @@ package com.gamss.android.feature.carddelete import androidx.lifecycle.ViewModel import com.gamss.android.core.common.AppResult -import com.gamss.android.domain.card.DeleteCardUseCase +import com.gamss.android.domain.card.DeleteAllCardsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -13,13 +13,13 @@ import javax.inject.Inject @HiltViewModel class CardDeleteViewModel @Inject constructor( - private val deleteCard: DeleteCardUseCase, + private val deleteAllCards: DeleteAllCardsUseCase, ) : ViewModel(), ContainerHost { override val container = container(CardDeleteState()) - /** 누를 때마다 종이가 한 단계씩 내려간다. 끝까지 내려가면 전달받은 카드만 삭제한다. */ - fun onShredTap(cardId: Long) = intent { + /** 누를 때마다 종이가 한 단계씩 내려간다. 끝까지 내려가면 모든 카드를 삭제한다. */ + fun onShredTap() = intent { if (state.isDeleting || state.shredTapCount >= SHRED_TOTAL_TAPS) return@intent val nextTapCount = state.shredTapCount + 1 @@ -29,7 +29,7 @@ class CardDeleteViewModel @Inject constructor( reduce { state.copy(isDeleting = true) } val result = coroutineScope { val minimumHold = launch { delay(SHRED_COMPLETE_HOLD_MS) } - val outcome = deleteCard(cardId) + val outcome = deleteAllCards() minimumHold.join() outcome } diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt index 64162152..94938910 100644 --- a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt @@ -3,6 +3,5 @@ package com.gamss.android.feature.carddelete.navigation import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable -/** 삭제 대상은 화면 밖에서 선택하고, 이 키에는 안정적인 식별자만 전달한다. */ @Serializable -data class CardDeleteKey(val cardId: Long) : NavKey +data object CardDeleteKey : NavKey diff --git a/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt b/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt index 47002274..42d8d1d3 100644 --- a/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt +++ b/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt @@ -1,7 +1,7 @@ package com.gamss.android.feature.carddelete import com.gamss.android.core.common.AppResult -import com.gamss.android.domain.card.DeleteCardUseCase +import com.gamss.android.domain.card.DeleteAllCardsUseCase import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -14,19 +14,19 @@ import org.orbitmvi.orbit.test.test @OptIn(ExperimentalCoroutinesApi::class) class CardDeleteViewModelTest { - private val deleteCard: DeleteCardUseCase = mockk() + private val deleteAllCards: DeleteAllCardsUseCase = mockk() @Test - fun `여섯 번째 탭에서 전달받은 카드만 한 번 삭제하고 완료 상태가 된다`() = runTest { - coEvery { deleteCard(CARD_ID) } returns AppResult.Success(Unit) + fun `여섯 번째 탭에서 모든 카드를 한 번 삭제하고 완료 상태가 된다`() = runTest { + coEvery { deleteAllCards() } returns AppResult.Success(Unit) - CardDeleteViewModel(deleteCard).test(this) { + CardDeleteViewModel(deleteAllCards).test(this) { repeat(SHRED_TOTAL_TAPS - 1) { tapIndex -> - containerHost.onShredTap(CARD_ID) + containerHost.onShredTap() expectState { copy(shredTapCount = tapIndex + 1) } } - containerHost.onShredTap(CARD_ID) + containerHost.onShredTap() expectState { copy(shredTapCount = SHRED_TOTAL_TAPS) } expectState { copy(shredTapCount = SHRED_TOTAL_TAPS, isDeleting = true) } advanceTimeBy(SHRED_COMPLETE_HOLD_MS) @@ -34,29 +34,25 @@ class CardDeleteViewModelTest { expectSideEffect(CardDeleteSideEffect.ShredSuccess) } - coVerify(exactly = 1) { deleteCard(CARD_ID) } + coVerify(exactly = 1) { deleteAllCards() } } @Test fun `삭제에 실패하면 초기 상태로 돌아가 다시 시도할 수 있다`() = runTest { - coEvery { deleteCard(CARD_ID) } returns AppResult.Failure(IllegalStateException("실패")) + coEvery { deleteAllCards() } returns AppResult.Failure(IllegalStateException("실패")) - CardDeleteViewModel(deleteCard).test(this) { - repeat(SHRED_TOTAL_TAPS) { containerHost.onShredTap(CARD_ID) } + CardDeleteViewModel(deleteAllCards).test(this) { + repeat(SHRED_TOTAL_TAPS) { containerHost.onShredTap() } repeat(SHRED_TOTAL_TAPS) { tapIndex -> expectState { copy(shredTapCount = tapIndex + 1) } } expectState { copy(shredTapCount = SHRED_TOTAL_TAPS, isDeleting = true) } advanceTimeBy(SHRED_COMPLETE_HOLD_MS) expectState { CardDeleteState() } expectSideEffect(CardDeleteSideEffect.ShredFailure) - containerHost.onShredTap(CARD_ID) + containerHost.onShredTap() expectState { copy(shredTapCount = 1) } } - coVerify(exactly = 1) { deleteCard(CARD_ID) } - } - - private companion object { - const val CARD_ID = 42L + coVerify(exactly = 1) { deleteAllCards() } } } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 114a7b82..96d22772 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -72,7 +72,6 @@ import org.orbitmvi.orbit.compose.collectSideEffect fun ChatRoomScreen( conversationId: Long, onCardClose: () -> Unit, - onCardDeleteClick: (Long) -> Unit, modifier: Modifier = Modifier, viewModel: ChatRoomViewModel = hiltViewModel(), ) { @@ -120,7 +119,6 @@ fun ChatRoomScreen( is EndFlow.CardReady -> CardBottomSheet( card = endFlow.card, onDismiss = onCardClose, - onDeleteClick = { onCardDeleteClick(endFlow.card.id) }, ) EndFlow.NotStarted, EndFlow.Ending, @@ -160,7 +158,6 @@ private fun EndConversationDialog( private fun CardBottomSheet( card: Card, onDismiss: () -> Unit, - onDeleteClick: () -> Unit, ) { ModalBottomSheet(onDismissRequest = onDismiss) { Column( @@ -181,9 +178,6 @@ private fun CardBottomSheet( style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - TextButton(onClick = onDeleteClick) { - Text(text = stringResource(R.string.chat_room_card_delete_button)) - } } } } diff --git a/feature/chat/src/main/res/values/strings.xml b/feature/chat/src/main/res/values/strings.xml index 38d07225..45001cf6 100644 --- a/feature/chat/src/main/res/values/strings.xml +++ b/feature/chat/src/main/res/values/strings.xml @@ -4,7 +4,6 @@ 감정 기록 생성 시 대화는 종료되며,\n더 이상 대화를 이어갈 수 없어요. 뒤로가기 감정 확인하기 - 카드 삭제 혼자 감당하지 않아도 괜찮아요. 대화에서 도움이 필요하다는 신호가 확인됐어요.\n전문가와 이야기해 보는 걸 추천드려요. 24시간 상담이 가능해요. 누르면 바로 연결돼요. 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 7567a2a4..27cc26a2 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 @@ -219,7 +219,7 @@ internal class CountingCardRepository( ) } - override suspend fun deleteCard(cardId: Long): AppResult = error("채팅방 테스트에서 쓰지 않는다") + override suspend fun deleteAllCards(): AppResult = error("채팅방 테스트에서 쓰지 않는다") } internal fun message(id: Long, conversationId: Long, sender: MessageSender, content: String) = Message( 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 f5ab2d86..969890a4 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 @@ -117,7 +117,7 @@ private object NoCardRepository : CardRepository { summary: String, ): AppResult = error("홈 테스트에서 쓰지 않는다") - override suspend fun deleteCard(cardId: Long): AppResult = error("홈 테스트에서 쓰지 않는다") + override suspend fun deleteAllCards(): AppResult = error("홈 테스트에서 쓰지 않는다") } private object PassThroughSummarizer : DiarySummarizer { diff --git a/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt b/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt index 297a95e9..6165edfa 100644 --- a/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt +++ b/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt @@ -25,6 +25,7 @@ fun SettingScreen( onAccountInfoClick: () -> Unit, onServiceTermsClick: () -> Unit, onPrivacyPolicyClick: () -> Unit, + onCardDeleteClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -64,6 +65,12 @@ fun SettingScreen( title = stringResource(R.string.setting_list_app_version), additionalInfo = appVersion, ) + onCardDeleteClick?.let { onClick -> + SettingListItem( + title = stringResource(R.string.setting_list_card_delete), + onClick = onClick, + ) + } } } } diff --git a/feature/setting/src/main/res/values/strings.xml b/feature/setting/src/main/res/values/strings.xml index 4b9ec673..cbbd7bed 100644 --- a/feature/setting/src/main/res/values/strings.xml +++ b/feature/setting/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ 서비스 이용약관 개인정보 처리방침 앱 버전 + 카드 전체 삭제 닉네임 변경 이메일 로그아웃 From ca5b16b6f688994608db8ff4fe250c86bca3478d Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:16:29 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20=EC=B9=B4=EB=93=9C=20=EB=8B=A8?= =?UTF-8?q?=EA=B1=B4=C2=B7=EA=B0=90=EC=A0=95=EB=B3=84=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카드 전체 삭제만 연결돼 있던 카드 삭제 기능에 단건 삭제(DELETE /api/cards/{cardId})와 감정별 일괄 삭제(DELETE /api/cards/emotions/{emotion})를 추가로 연결한다. --- .../android/data/remote/card/CardService.kt | 9 +++ .../data/repository/CardRepositoryImpl.kt | 23 +++++++ .../data/repository/CardRepositoryImplTest.kt | 67 +++++++++++++++++++ .../android/domain/card/CardRepository.kt | 6 ++ .../android/domain/card/DeleteCardUseCase.kt | 11 +++ .../card/DeleteCardsByEmotionUseCase.kt | 13 ++++ .../domain/card/CreateCardUseCaseTest.kt | 5 ++ .../domain/card/DeleteAllCardsUseCaseTest.kt | 5 ++ .../domain/card/DeleteCardUseCaseTest.kt | 40 +++++++++++ .../card/DeleteCardsByEmotionUseCaseTest.kt | 39 +++++++++++ .../conversation/ConversationSessionTest.kt | 5 ++ .../android/feature/chat/ChatRoomTestFakes.kt | 5 ++ .../android/feature/home/HomeTestFakes.kt | 5 ++ 13 files changed, 233 insertions(+) create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/card/DeleteCardUseCase.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/card/DeleteCardsByEmotionUseCase.kt create mode 100644 domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardUseCaseTest.kt create mode 100644 domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardsByEmotionUseCaseTest.kt 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 e8c659a3..10588925 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 @@ -7,6 +7,7 @@ import com.gamss.android.data.remote.model.response.ApiResponse import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.POST +import retrofit2.http.Path internal interface CardService { @@ -17,4 +18,12 @@ internal interface CardService { /** 모든 카드와 카드가 나온 채팅방을 함께 삭제한다. */ @DELETE("/api/cards") suspend fun deleteAllCards(): ApiResponse + + /** 카드 한 장과 카드가 나온 채팅방을 함께 삭제한다. envelope 의 success 만 보고 data 는 쓰지 않는다. */ + @DELETE("/api/cards/{cardId}") + suspend fun deleteCard(@Path("cardId") cardId: Long): ApiResponse + + /** 해당 감정인 카드와 카드가 나온 채팅방을 모두 함께 삭제한다. */ + @DELETE("/api/cards/emotions/{emotion}") + suspend fun deleteCardsByEmotion(@Path("emotion") emotion: String): ApiResponse } 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 327f421e..36be302e 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 @@ -53,4 +53,27 @@ internal class CardRepositoryImpl @Inject constructor( checkNotNull(response.data?.deletedCount) { "No deleted card count" } Unit } + + 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 + } + } + } + + override suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult = runCatchingApiCall { + val response = cardService.deleteCardsByEmotion(character.toServerEmotionType()) + response.throwIfFailed() + checkNotNull(response.data?.deletedCount) { "No deleted card count" } + Unit + } } 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 index 62a4d8af..f9aa35b5 100644 --- a/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt +++ b/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt @@ -7,6 +7,7 @@ import com.gamss.android.data.remote.card.model.response.CardDeleteResponse import com.gamss.android.data.remote.model.response.ApiError import com.gamss.android.data.remote.model.response.ApiResponse import com.gamss.android.domain.auth.SessionExpiredException +import com.gamss.android.domain.emotion.EmotionCharacter import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -64,4 +65,70 @@ class CardRepositoryImplTest { assertTrue(result is AppResult.Failure) } + + @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, + ) + } + + @Test + fun `감정별 카드 삭제 요청을 전달한다`() = runTest { + coEvery { cardService.deleteCardsByEmotion("ANGER") } returns ApiResponse( + success = true, + data = CardDeleteResponse(deletedCount = 0), + ) + + assertEquals(AppResult.Success(Unit), repository.deleteCardsByEmotion(EmotionCharacter.ANGER)) + } + + @Test + fun `감정별 삭제 성공 응답에 삭제 수가 없으면 실패로 전달한다`() = runTest { + coEvery { cardService.deleteCardsByEmotion("ANGER") } returns ApiResponse(success = true) + + val result = repository.deleteCardsByEmotion(EmotionCharacter.ANGER) + + assertTrue(result is AppResult.Failure) + } + + @Test + fun `감정별 삭제 실패 envelope는 실패로 전달한다`() = runTest { + coEvery { cardService.deleteCardsByEmotion("ANGER") } returns ApiResponse( + success = false, + error = ApiError(code = "INVALID_INPUT", message = "지원하지 않는 감정"), + ) + + val result = repository.deleteCardsByEmotion(EmotionCharacter.ANGER) + + assertEquals( + "INVALID_INPUT", + ((result as AppResult.Failure).throwable as ApiException).code, + ) + } } 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 3e55c4a9..c7b62b48 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 @@ -14,4 +14,10 @@ interface CardRepository { /** 모든 카드와 카드가 나온 채팅방을 함께 삭제한다. 되돌릴 수 없다. */ suspend fun deleteAllCards(): AppResult + + /** 카드 한 장과 카드가 나온 채팅방을 함께 삭제한다. 되돌릴 수 없다. */ + suspend fun deleteCard(cardId: Long): AppResult + + /** 해당 감정인 카드와 카드가 나온 채팅방을 모두 함께 삭제한다. 되돌릴 수 없다. */ + suspend fun deleteCardsByEmotion(character: EmotionCharacter): 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..7098b910 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/card/DeleteCardUseCase.kt @@ -0,0 +1,11 @@ +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/DeleteCardsByEmotionUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/DeleteCardsByEmotionUseCase.kt new file mode 100644 index 00000000..12881626 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/card/DeleteCardsByEmotionUseCase.kt @@ -0,0 +1,13 @@ +package com.gamss.android.domain.card + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.emotion.EmotionCharacter +import com.gamss.android.domain.usecase.UseCase +import javax.inject.Inject + +class DeleteCardsByEmotionUseCase @Inject constructor( + private val cardRepository: CardRepository, +) : UseCase> { + override suspend fun invoke(params: EmotionCharacter): AppResult = + cardRepository.deleteCardsByEmotion(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 3eb7a48a..d15ff862 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 @@ -31,6 +31,11 @@ class CreateCardUseCaseTest { } override suspend fun deleteAllCards(): AppResult = AppResult.Success(Unit) + + override suspend fun deleteCard(cardId: Long): AppResult = AppResult.Success(Unit) + + override suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult = + AppResult.Success(Unit) } @Test diff --git a/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteAllCardsUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteAllCardsUseCaseTest.kt index 1c1942af..07b5616d 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteAllCardsUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteAllCardsUseCaseTest.kt @@ -21,6 +21,11 @@ class DeleteAllCardsUseCaseTest { deleteAllCalled = true return AppResult.Success(Unit) } + + override suspend fun deleteCard(cardId: Long): AppResult = error("사용하지 않음") + + override suspend fun deleteCardsByEmotion(character: EmotionCharacter): 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..ff61779d --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardUseCaseTest.kt @@ -0,0 +1,40 @@ +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 + +class DeleteCardUseCaseTest { + + private class RecordingRepository : CardRepository { + var deletedCardId: Long? = null + + 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 { + deletedCardId = cardId + return AppResult.Success(Unit) + } + + override suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult = + error("사용하지 않음") + } + + @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/card/DeleteCardsByEmotionUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardsByEmotionUseCaseTest.kt new file mode 100644 index 00000000..be6deff9 --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardsByEmotionUseCaseTest.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 + +class DeleteCardsByEmotionUseCaseTest { + + private class RecordingRepository : CardRepository { + var deletedCharacter: EmotionCharacter? = null + + 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 = error("사용하지 않음") + + override suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult { + deletedCharacter = character + return AppResult.Success(Unit) + } + } + + @Test + fun 감정별_카드_삭제를_리포지토리에_위임한다() = runBlocking { + val repository = RecordingRepository() + + val result = DeleteCardsByEmotionUseCase(repository)(EmotionCharacter.ANGER) + + assertEquals(EmotionCharacter.ANGER, repository.deletedCharacter) + 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 b6b4be8b..45721ed9 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 @@ -229,6 +229,11 @@ class ConversationSessionTest { ) override suspend fun deleteAllCards(): AppResult = AppResult.Success(Unit) + + override suspend fun deleteCard(cardId: Long): AppResult = AppResult.Success(Unit) + + override suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult = + AppResult.Success(Unit) } private class FakeConversationRepository( 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 27cc26a2..a69cb271 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 @@ -220,6 +220,11 @@ internal class CountingCardRepository( } override suspend fun deleteAllCards(): AppResult = error("채팅방 테스트에서 쓰지 않는다") + + override suspend fun deleteCard(cardId: Long): AppResult = error("채팅방 테스트에서 쓰지 않는다") + + override suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult = + error("채팅방 테스트에서 쓰지 않는다") } internal fun message(id: Long, conversationId: Long, sender: MessageSender, content: String) = Message( 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 969890a4..8ea5b15a 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 @@ -118,6 +118,11 @@ private object NoCardRepository : CardRepository { ): AppResult = error("홈 테스트에서 쓰지 않는다") override suspend fun deleteAllCards(): AppResult = error("홈 테스트에서 쓰지 않는다") + + override suspend fun deleteCard(cardId: Long): AppResult = error("홈 테스트에서 쓰지 않는다") + + override suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult = + error("홈 테스트에서 쓰지 않는다") } private object PassThroughSummarizer : DiarySummarizer { From 6895d4ff6fec434cde1ecfa6a7f49fbdc70deab3 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:16:43 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20=ED=8C=8C=EC=87=84=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=83=81=ED=83=9C=20=ED=86=A0=EA=B8=80=EC=9D=B4=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A5=BC=20=EC=93=B0?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToggleCell이 GamssText 대신 M3 Text를 직접 써서 테마 기본값을 놓치고 있었고, 상태 로우 세로 padding도 GamssSpacing 토큰에 없는 25dp였다. --- .../android/feature/carddelete/component/ShredStatusRow.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStatusRow.kt b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStatusRow.kt index 85820c31..b96232f2 100644 --- a/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStatusRow.kt +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStatusRow.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -26,6 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.component.GamssText import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.feature.carddelete.R @@ -57,7 +57,7 @@ fun ShredStatusRow( strokeWidth = strokeWidth, ) } - .padding(horizontal = GamssTheme.spacing.spacing400, vertical = 25.dp), + .padding(horizontal = GamssTheme.spacing.spacing400, vertical = GamssTheme.spacing.spacing500), verticalAlignment = Alignment.CenterVertically, ) { StatusPillToggle(isOn = isShredding) @@ -108,7 +108,7 @@ private fun ToggleCell(label: String, selected: Boolean, selectedColor: Color) { .background(backgroundColor), contentAlignment = Alignment.Center, ) { - Text( + GamssText( text = label, style = GamssTheme.typography.body5Medium, color = if (selected) GamssTheme.colors.white else GamssTheme.colors.gray500, From 6817668ac6b2ea32997ac5c5769a4599c2372e29 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:36:41 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20=EC=84=A4=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=98=20debug=20=EC=A0=84=EC=9A=A9=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C=20=EC=A7=84=EC=9E=85=EC=A0=90=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/kotlin/com/gamss/android/app/main/MainScreen.kt | 6 ------ .../gamss/android/feature/setting/main/SettingScreen.kt | 7 ------- feature/setting/src/main/res/values/strings.xml | 1 - 3 files changed, 14 deletions(-) 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 fe607cdf..9133e835 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 @@ -16,7 +16,6 @@ import androidx.compose.ui.graphics.Color import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay -import com.gamss.android.app.BuildConfig import com.gamss.android.app.navigation.Navigator import com.gamss.android.app.navigation.bottomBarItems import com.gamss.android.app.navigation.keys @@ -109,11 +108,6 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { onAccountInfoClick = { navigator.navigate(AccountInfoKey) }, onServiceTermsClick = { navigator.navigate(WebViewKey(GamssWebPage.ServiceTerms)) }, onPrivacyPolicyClick = { navigator.navigate(WebViewKey(GamssWebPage.PrivacyPolicy)) }, - onCardDeleteClick = if (BuildConfig.BUILD_TYPE == "debug") { - { navigator.navigate(CardDeleteKey) } - } else { - null - }, ) } entry { diff --git a/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt b/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt index 6165edfa..297a95e9 100644 --- a/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt +++ b/feature/setting/src/main/java/com/gamss/android/feature/setting/main/SettingScreen.kt @@ -25,7 +25,6 @@ fun SettingScreen( onAccountInfoClick: () -> Unit, onServiceTermsClick: () -> Unit, onPrivacyPolicyClick: () -> Unit, - onCardDeleteClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -65,12 +64,6 @@ fun SettingScreen( title = stringResource(R.string.setting_list_app_version), additionalInfo = appVersion, ) - onCardDeleteClick?.let { onClick -> - SettingListItem( - title = stringResource(R.string.setting_list_card_delete), - onClick = onClick, - ) - } } } } diff --git a/feature/setting/src/main/res/values/strings.xml b/feature/setting/src/main/res/values/strings.xml index cbbd7bed..4b9ec673 100644 --- a/feature/setting/src/main/res/values/strings.xml +++ b/feature/setting/src/main/res/values/strings.xml @@ -5,7 +5,6 @@ 서비스 이용약관 개인정보 처리방침 앱 버전 - 카드 전체 삭제 닉네임 변경 이메일 로그아웃