diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 78195749..ef5b6ece 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -106,6 +106,7 @@ dependencies { implementation(projects.feature.login) implementation(projects.feature.onboarding) 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 de1355c7..8063a072 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 @@ -37,6 +37,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 @@ -126,6 +128,12 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { onPrivacyPolicyClick = { navigator.navigate(WebViewKey(GamssWebPage.PrivacyPolicy)) }, ) } + entry(metadata = detailSlideTransition) { + CardDeleteScreen( + onBackClick = navigator::goBack, + onDeleteComplete = navigator::finishCurrentFlow, + ) + } entry(metadata = detailSlideTransition) { AccountInfoScreen( onBackClick = navigator::goBack, 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..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을 두면 색이 * 겹쳐 보이는 잔상이 생깁니다. @@ -38,15 +39,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 +87,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 00000000..eec0f846 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-nodpi/img_paper_strip.png differ 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 6ea5c4cc..3ba2006c 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,6 +1,7 @@ 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 @@ -19,7 +20,15 @@ internal interface CardService { @POST("/api/cards") suspend fun createCard(@Body request: CreateCardRequest): ApiResponse + /** 모든 카드와 카드가 나온 채팅방을 함께 삭제한다. */ + @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/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 b2f19652..538bc463 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 @@ -56,6 +56,13 @@ internal class CardRepositoryImpl @Inject constructor( } } + override suspend fun deleteAllCards(): AppResult = runCatchingApiCall { + val response = cardService.deleteAllCards() + response.throwIfFailed() + checkNotNull(response.data?.deletedCount) { "No deleted card count" } + Unit + } + override suspend fun deleteCard(cardId: Long): AppResult { val result = runCatchingApiCall { cardService.deleteCard(cardId).throwIfFailed() @@ -71,4 +78,11 @@ internal class CardRepositoryImpl @Inject constructor( } } } + + 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 ac951fbd..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 @@ -3,12 +3,16 @@ 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 +import com.gamss.android.domain.emotion.EmotionCharacter 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 { @@ -17,7 +21,53 @@ class CardRepositoryImplTest { private val repository = CardRepositoryImpl(cardService) @Test - fun `카드 삭제 요청을 전달한다`() = runTest { + fun `모든 카드 삭제 요청을 전달한다`() = runTest { + coEvery { cardService.deleteAllCards() } returns ApiResponse( + success = true, + data = CardDeleteResponse(deletedCount = 0), + ) + + assertEquals(AppResult.Success(Unit), repository.deleteAllCards()) + } + + @Test + fun `200이어도 실패 envelope면 실패로 전달한다`() = runTest { + coEvery { cardService.deleteAllCards() } returns ApiResponse( + success = false, + error = ApiError(code = "CARD_DELETE_FAILED", message = "삭제 실패"), + ) + + val result = repository.deleteAllCards() + + assertEquals( + "CARD_DELETE_FAILED", + ((result as AppResult.Failure).throwable as ApiException).code, + ) + } + + @Test + fun `실패 envelope의 인증 만료는 세션 만료로 전달한다`() = runTest { + coEvery { cardService.deleteAllCards() } returns ApiResponse( + success = false, + error = ApiError(code = "EXPIRED_TOKEN", message = "만료"), + ) + + val result = repository.deleteAllCards() + + assertTrue((result as AppResult.Failure).throwable is SessionExpiredException) + } + + @Test + fun `성공 응답에 삭제 수가 없으면 실패로 전달한다`() = runTest { + coEvery { cardService.deleteAllCards() } returns ApiResponse(success = true) + + val result = repository.deleteAllCards() + + 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)) @@ -47,4 +97,38 @@ class CardRepositoryImplTest { ((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 494f0f7b..62643bbe 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 @@ -16,6 +16,12 @@ interface CardRepository { summary: String, ): AppResult + /** 모든 카드와 카드가 나온 채팅방을 함께 삭제한다. 되돌릴 수 없다. */ + 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/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/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 55f55eda..ec962ab9 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 @@ -36,7 +36,12 @@ class CreateCardUseCaseTest { override suspend fun getCardsByDate(date: LocalDate): AppResult> = AppResult.Success(emptyList()) + override suspend fun deleteAllCards(): AppResult = error("사용하지 않음") + 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/DeleteAllCardsUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteAllCardsUseCaseTest.kt new file mode 100644 index 00000000..77204e31 --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteAllCardsUseCaseTest.kt @@ -0,0 +1,43 @@ +package com.gamss.android.domain.card + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.emotion.EmotionCharacter +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate + +class DeleteAllCardsUseCaseTest { + + private class RecordingRepository : CardRepository { + var deleteAllCalled = false + + override suspend fun getCardsByDate(date: LocalDate): AppResult> = error("사용하지 않음") + + override suspend fun createCard( + conversationId: Long, + character: EmotionCharacter, + summary: String, + ): AppResult = error("사용하지 않음") + + override suspend fun deleteAllCards(): AppResult { + deleteAllCalled = true + return AppResult.Success(Unit) + } + + override suspend fun deleteCard(cardId: Long): AppResult = error("사용하지 않음") + + override suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult = + error("사용하지 않음") + } + + @Test + fun 모든_카드_삭제를_리포지토리에_위임한다() = runBlocking { + val repository = RecordingRepository() + + val result = DeleteAllCardsUseCase(repository)() + + assertEquals(true, repository.deleteAllCalled) + assertEquals(AppResult.Success(Unit), result) + } +} 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 285749cb..7648b2f5 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 @@ -21,10 +21,15 @@ class DeleteCardUseCaseTest { 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 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..829ce288 --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/card/DeleteCardsByEmotionUseCaseTest.kt @@ -0,0 +1,42 @@ +package com.gamss.android.domain.card + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.emotion.EmotionCharacter +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate + +class DeleteCardsByEmotionUseCaseTest { + + private class RecordingRepository : CardRepository { + var deletedCharacter: EmotionCharacter? = null + + override suspend fun getCardsByDate(date: LocalDate): AppResult> = error("사용하지 않음") + + 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 35e3ce79..b2a48d3e 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 @@ -308,7 +308,12 @@ class ConversationSessionTest { ), ) + override suspend fun deleteAllCards(): AppResult = error("사용하지 않음") + override suspend fun deleteCard(cardId: Long): AppResult = error("사용하지 않음") + + override suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult = + error("사용하지 않음") } private class FakeConversationRepository( diff --git a/feature/calendar/src/test/java/com/gamss/android/feature/calendar/CalendarViewModelTest.kt b/feature/calendar/src/test/java/com/gamss/android/feature/calendar/CalendarViewModelTest.kt index cf5e4dd1..cda16141 100644 --- a/feature/calendar/src/test/java/com/gamss/android/feature/calendar/CalendarViewModelTest.kt +++ b/feature/calendar/src/test/java/com/gamss/android/feature/calendar/CalendarViewModelTest.kt @@ -165,6 +165,8 @@ class CalendarViewModelTest { summary: String, ): AppResult = error("Not used by the calendar") + override suspend fun deleteAllCards(): AppResult = error("Not used by the calendar") + override suspend fun deleteCard(cardId: Long): AppResult { deletedCardId = cardId return if (deleteShouldFail) { @@ -173,6 +175,9 @@ class CalendarViewModelTest { AppResult.Success(Unit) } } + + override suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult = + error("Not used by the calendar") } private class DelayingCardRepository : CardRepository { @@ -204,7 +209,12 @@ class CalendarViewModelTest { summary: String, ): AppResult = error("Not used by the calendar") + override suspend fun deleteAllCards(): AppResult = error("Not used by the calendar") + override suspend fun deleteCard(cardId: Long): AppResult = error("Not used by the calendar") + + override suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult = + error("Not used by the calendar") } private fun card(date: LocalDate) = Card( 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..b27e0cba --- /dev/null +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/CardDeleteScreen.kt @@ -0,0 +1,135 @@ +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( + 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, + 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..7558ff0d --- /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.DeleteAllCardsUseCase +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 deleteAllCards: DeleteAllCardsUseCase, +) : ViewModel(), ContainerHost { + + override val container = container(CardDeleteState()) + + /** 누를 때마다 종이가 한 단계씩 내려간다. 끝까지 내려가면 모든 카드를 삭제한다. */ + fun onShredTap() = 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 = deleteAllCards() + 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..b96232f2 --- /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.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.component.GamssText +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 = GamssTheme.spacing.spacing500), + 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, + ) { + GamssText( + 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..d3495401 --- /dev/null +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/component/ShredStrips.kt @@ -0,0 +1,106 @@ +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.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), + ) + } + } + + 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(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 +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..94938910 --- /dev/null +++ b/feature/carddelete/src/main/java/com/gamss/android/feature/carddelete/navigation/CardDeleteKey.kt @@ -0,0 +1,7 @@ +package com.gamss.android.feature.carddelete.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object CardDeleteKey : 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..42d8d1d3 --- /dev/null +++ b/feature/carddelete/src/test/java/com/gamss/android/feature/carddelete/CardDeleteViewModelTest.kt @@ -0,0 +1,58 @@ +package com.gamss.android.feature.carddelete + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.card.DeleteAllCardsUseCase +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 deleteAllCards: DeleteAllCardsUseCase = mockk() + + @Test + fun `여섯 번째 탭에서 모든 카드를 한 번 삭제하고 완료 상태가 된다`() = runTest { + coEvery { deleteAllCards() } returns AppResult.Success(Unit) + + CardDeleteViewModel(deleteAllCards).test(this) { + repeat(SHRED_TOTAL_TAPS - 1) { tapIndex -> + containerHost.onShredTap() + expectState { copy(shredTapCount = tapIndex + 1) } + } + + containerHost.onShredTap() + 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) { deleteAllCards() } + } + + @Test + fun `삭제에 실패하면 초기 상태로 돌아가 다시 시도할 수 있다`() = runTest { + coEvery { deleteAllCards() } returns AppResult.Failure(IllegalStateException("실패")) + + 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() + expectState { copy(shredTapCount = 1) } + } + + 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 8afc0cd3..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 @@ -116,7 +116,10 @@ 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, + ) EndFlow.NotStarted, EndFlow.Ending, EndFlow.CreatingCard, 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 8a10a84c..5d3cd2cc 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 @@ -234,7 +234,12 @@ 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 32795419..95ee133e 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 @@ -129,7 +129,12 @@ private object NoCardRepository : CardRepository { 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 = + 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 3c0d47d0..06e6242b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,7 @@ include(":feature:calendar") include(":feature:login") include(":feature:emotion") include(":feature:setting") +include(":feature:carddelete") include(":feature:webview") include(":core:designsystem") include(":feature:onboarding")