diff --git a/challenge/data/src/main/kotlin/com/ruleup/challenge/data/api/ChallengeApi.kt b/challenge/data/src/main/kotlin/com/ruleup/challenge/data/api/ChallengeApi.kt index 1a3c45a38..3a8d92e28 100644 --- a/challenge/data/src/main/kotlin/com/ruleup/challenge/data/api/ChallengeApi.kt +++ b/challenge/data/src/main/kotlin/com/ruleup/challenge/data/api/ChallengeApi.kt @@ -44,9 +44,12 @@ import retrofit2.http.Path import retrofit2.http.Query interface ChallengeApi { - // 생성 화면 추천 루틴 + // 생성 화면 추천 루틴 → /api/v1/challenges/recommendations?exclude=2,11,31 + // exclude 는 #598 로 BE 에 요청한 파라미터다(쉼표 구분 templateId). 서버가 모르면 무시하고 같은 3개를 준다. @GET("v1/challenges/recommendations") - suspend fun getRoutineTemplates(): BaseResponse + suspend fun getRoutineTemplates( + @Query("exclude") exclude: String? = null, + ): BaseResponse // 경로 B: 설명 입력 → LLM 5-Step 초안. @POST("v1/challenges/draft") diff --git a/challenge/data/src/main/kotlin/com/ruleup/challenge/data/repository/ChallengeRepositoryImpl.kt b/challenge/data/src/main/kotlin/com/ruleup/challenge/data/repository/ChallengeRepositoryImpl.kt index fad5f9951..b818f9289 100644 --- a/challenge/data/src/main/kotlin/com/ruleup/challenge/data/repository/ChallengeRepositoryImpl.kt +++ b/challenge/data/src/main/kotlin/com/ruleup/challenge/data/repository/ChallengeRepositoryImpl.kt @@ -47,9 +47,9 @@ class ChallengeRepositoryImpl private val api: ChallengeApi, private val imageReader: ImageReader, ) : ChallengeRepository { - override suspend fun getRoutineTemplates(): List = + override suspend fun getRoutineTemplates(exclude: Set): List = api - .getRoutineTemplates() + .getRoutineTemplates(exclude = exclude.takeIf { it.isNotEmpty() }?.joinToString(",")) .getOrThrow() .toDomain() diff --git a/challenge/data/src/test/kotlin/com/ruleup/challenge/data/repository/FailingChallengeApi.kt b/challenge/data/src/test/kotlin/com/ruleup/challenge/data/repository/FailingChallengeApi.kt index 11c11a214..0c661e3ff 100644 --- a/challenge/data/src/test/kotlin/com/ruleup/challenge/data/repository/FailingChallengeApi.kt +++ b/challenge/data/src/test/kotlin/com/ruleup/challenge/data/repository/FailingChallengeApi.kt @@ -42,7 +42,7 @@ class FailingChallengeApi( ) : ChallengeApi { private fun failed(): BaseResponse = BaseResponse(success = false, data = null, error = error) - override suspend fun getRoutineTemplates(): BaseResponse = failed() + override suspend fun getRoutineTemplates(exclude: String?): BaseResponse = failed() override suspend fun createDraft(request: DraftRequest): BaseResponse = failed() diff --git a/challenge/domain/src/main/kotlin/com/ruleup/challenge/domain/repository/ChallengeRepository.kt b/challenge/domain/src/main/kotlin/com/ruleup/challenge/domain/repository/ChallengeRepository.kt index b5bbb4d22..9c330567f 100644 --- a/challenge/domain/src/main/kotlin/com/ruleup/challenge/domain/repository/ChallengeRepository.kt +++ b/challenge/domain/src/main/kotlin/com/ruleup/challenge/domain/repository/ChallengeRepository.kt @@ -19,8 +19,8 @@ import com.ruleup.challenge.domain.entity.RoutineDescription import com.ruleup.challenge.domain.entity.RoutineTemplate interface ChallengeRepository { - /** 생성 화면에 항상 떠 있는 추천 루틴. */ - suspend fun getRoutineTemplates(): List + /** 생성 화면에 항상 떠 있는 추천 루틴. [exclude] 는 이미 보여 준 템플릿 — 「다른 추천 보기」에서 넘긴다. */ + suspend fun getRoutineTemplates(exclude: Set = emptySet()): List /** 루틴 설명으로 초안을 만든다. */ suspend fun createDraft(description: RoutineDescription): DraftResult diff --git a/challenge/domain/src/testFixtures/kotlin/com/ruleup/challenge/domain/fake/FakeChallengeRepository.kt b/challenge/domain/src/testFixtures/kotlin/com/ruleup/challenge/domain/fake/FakeChallengeRepository.kt index 3ad095d55..aa4fb9166 100644 --- a/challenge/domain/src/testFixtures/kotlin/com/ruleup/challenge/domain/fake/FakeChallengeRepository.kt +++ b/challenge/domain/src/testFixtures/kotlin/com/ruleup/challenge/domain/fake/FakeChallengeRepository.kt @@ -22,6 +22,7 @@ import com.ruleup.challenge.domain.entity.ModerationState import com.ruleup.challenge.domain.entity.MyChallengeFilter import com.ruleup.challenge.domain.entity.MyChallengePage import com.ruleup.challenge.domain.entity.RoutineDescription +import com.ruleup.challenge.domain.entity.RoutineTemplate import com.ruleup.challenge.domain.entity.VerificationConfig import com.ruleup.challenge.domain.entity.VerificationMethod import com.ruleup.challenge.domain.entity.VerificationType @@ -45,6 +46,7 @@ class FakeChallengeRepository( private val join: ((String) -> JoinResult)? = null, private val setupInfo: ((String) -> ChallengeSetupInfo)? = null, private val leave: ((String) -> LeaveResult)? = null, + private val templates: ((exclude: Set) -> List)? = null, ) : ChallengeRepository { var lastCommand: CreateChallengeCommand? = null private set @@ -65,7 +67,13 @@ class FakeChallengeRepository( var lastUpdate: ChallengeUpdate? = null private set - override suspend fun getRoutineTemplates() = throw NotImplementedError() + /** 추천 루틴을 어떤 제외 목록으로 물었는지. */ + val templateExcludes = mutableListOf>() + + override suspend fun getRoutineTemplates(exclude: Set): List { + templateExcludes += exclude + return templates?.invoke(exclude) ?: throw NotImplementedError() + } override suspend fun createDraft(description: RoutineDescription): DraftResult { calls += "createDraft" diff --git a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/ChallengeInputScreen.kt b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/ChallengeInputScreen.kt index be833a48f..9d60b6cfc 100644 --- a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/ChallengeInputScreen.kt +++ b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/ChallengeInputScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog @@ -79,17 +80,25 @@ fun ChallengeInputContent( RoutineDescriptionBox(state = state, onIntent = onIntent) - OrDivider() + OrDivider( + // 처음 불러오기 전이거나 실패해 보여 줄 추천이 없으면 「다른 추천」도 없다. + onRefresh = + { onIntent(CreateChallengeIntent.RefreshTemplates) } + .takeIf { state.templates.isNotEmpty() }, + refreshEnabled = !state.isLoadingTemplates && !state.isDrafting, + ) - state.templates.forEach { template -> - TemplateCard( - template = template, - enabled = !state.isDrafting, - onClick = { onIntent(CreateChallengeIntent.SelectTemplate(template.templateId)) }, - ) - } + // 새로 받는 동안 이전 추천을 누르면 바뀌기 직전의 루틴으로 초안이 만들어진다. if (state.isLoadingTemplates) { repeat(SKELETON_COUNT) { TemplateSkeleton() } + } else { + state.templates.forEach { template -> + TemplateCard( + template = template, + enabled = !state.isDrafting, + onClick = { onIntent(CreateChallengeIntent.SelectTemplate(template.templateId)) }, + ) + } } if (state.templatesFailed) { RetryRow(onRetry = { onIntent(CreateChallengeIntent.RetryTemplates) }) @@ -254,7 +263,10 @@ private fun SubmitButton( } @Composable -private fun OrDivider() { +private fun OrDivider( + onRefresh: (() -> Unit)?, + refreshEnabled: Boolean, +) { Row( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -263,6 +275,25 @@ private fun OrDivider() { HorizontalDivider(modifier = Modifier.weight(1f), color = RuleUpTheme.colors.border) Text("또는 바로 시작하기", color = RuleUpTheme.colors.textMuted, style = RuleUpTheme.typography.captionMedium) HorizontalDivider(modifier = Modifier.weight(1f), color = RuleUpTheme.colors.border) + if (onRefresh != null) { + Box( + modifier = + Modifier + .size(32.dp) + .clip(CircleShape) + .border(1.dp, RuleUpTheme.colors.border, CircleShape) + .background(RuleUpTheme.colors.surface) + .singleClickable(enabled = refreshEnabled, onClick = onRefresh), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_refresh), + contentDescription = "다른 추천 보기", + tint = if (refreshEnabled) RuleUpTheme.colors.textSecondary else RuleUpTheme.colors.textMuted, + modifier = Modifier.size(16.dp), + ) + } + } } } diff --git a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeIntent.kt b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeIntent.kt index 6a9f00ecd..74e019f40 100644 --- a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeIntent.kt +++ b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeIntent.kt @@ -44,6 +44,9 @@ sealed interface CreateChallengeIntent : MviIntent { /** 추천 영역만 재시도. */ data object RetryTemplates : CreateChallengeIntent + /** 「다른 추천 보기」. 지금까지 본 추천을 빼고 새로 받는다. */ + data object RefreshTemplates : CreateChallengeIntent + // 확인 화면 data class SetTitle( val title: String, diff --git a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeReducerEvent.kt b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeReducerEvent.kt index 997d638da..72a2b0f1d 100644 --- a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeReducerEvent.kt +++ b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeReducerEvent.kt @@ -22,10 +22,14 @@ sealed interface CreateChallengeReducerEvent : ReducerEvent { data class TemplatesLoaded( val templates: List, + val seenTemplateIds: Set, ) : CreateChallengeReducerEvent data object TemplatesFailed : CreateChallengeReducerEvent + /** 다른 추천을 못 받았다 — 보던 추천은 그대로 둔다. */ + data object TemplatesRefreshFailed : CreateChallengeReducerEvent + data object Drafting : CreateChallengeReducerEvent data object DraftFailed : CreateChallengeReducerEvent diff --git a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeState.kt b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeState.kt index e89e4c37f..a46dd4297 100644 --- a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeState.kt +++ b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeState.kt @@ -28,6 +28,8 @@ data class CreateChallengeState( val isLoadingTemplates: Boolean, // 추천 영역만 재시도 val templatesFailed: Boolean, + // 「다른 추천 보기」로 지금까지 본 추천 — 다음 요청에서 뺀다 + val seenTemplateIds: Set = emptySet(), // 확인 화면 val draftId: String?, val original: ChallengeDraft?, diff --git a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeViewModel.kt b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeViewModel.kt index 17fe6e13c..a96c798c2 100644 --- a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeViewModel.kt +++ b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeViewModel.kt @@ -92,6 +92,7 @@ class CreateChallengeViewModel CreateChallengeIntent.Exit -> exit() CreateChallengeIntent.RetryTemplates -> loadTemplates() + CreateChallengeIntent.RefreshTemplates -> refreshTemplates() is CreateChallengeIntent.SetRoutineDescription -> { savedStateHandle[KEY_ROUTINE_DESCRIPTION] = intent.description @@ -213,6 +214,7 @@ class CreateChallengeViewModel is CreateChallengeReducerEvent.TemplatesLoaded -> state.copy( templates = event.templates, + seenTemplateIds = event.seenTemplateIds, isLoadingTemplates = false, templatesFailed = false, ) @@ -220,6 +222,9 @@ class CreateChallengeViewModel CreateChallengeReducerEvent.TemplatesFailed -> state.copy(isLoadingTemplates = false, templatesFailed = true) + CreateChallengeReducerEvent.TemplatesRefreshFailed -> + state.copy(isLoadingTemplates = false) + CreateChallengeReducerEvent.Drafting -> state.copy(isDrafting = true, fallbackMessage = null) @@ -356,14 +361,30 @@ class CreateChallengeViewModel state.copy(isCreating = false, createdChallengeId = event.challengeId) } - private fun loadTemplates() { + private fun refreshTemplates() { + val state = currentState + loadTemplates(exclude = state.seenTemplateIds + state.templates.map { it.templateId }) + } + + private fun loadTemplates(exclude: Set = emptySet()) { if (currentState.isLoadingTemplates) return viewModelScope.launch { dispatch(CreateChallengeReducerEvent.TemplatesLoading) - runCatching { challengeRepository.getRoutineTemplates() } - .onSuccess { dispatch(CreateChallengeReducerEvent.TemplatesLoaded(it)) } - // 추천이 실패해도 설명 입력 경로는 살아 있어야 하므로 화면 전체를 에러로 만들지 않는다. - .onFailure { dispatch(CreateChallengeReducerEvent.TemplatesFailed) } + runCatching { challengeRepository.getRoutineTemplates(exclude) } + .onSuccess { templates -> + val ids = templates.map { it.templateId }.toSet() + // 받은 게 전부 이미 본 것이면(서버가 exclude 를 모르거나 추천을 다 돌았다) 처음부터 다시 센다. + val seen = if (exclude.isNotEmpty() && exclude.containsAll(ids)) ids else exclude + ids + dispatch(CreateChallengeReducerEvent.TemplatesLoaded(templates, seen)) + }.onFailure { + // 추천이 실패해도 설명 입력 경로는 살아 있어야 하므로 화면 전체를 에러로 만들지 않는다. + if (currentState.templates.isEmpty()) { + dispatch(CreateChallengeReducerEvent.TemplatesFailed) + } else { + dispatch(CreateChallengeReducerEvent.TemplatesRefreshFailed) + emitEffect(CreateChallengeEffect.ShowError("다른 추천을 불러오지 못했어요")) + } + } } } diff --git a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeViewModelTest.kt b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeViewModelTest.kt index 83f4a0288..0561c282b 100644 --- a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeViewModelTest.kt +++ b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeViewModelTest.kt @@ -12,6 +12,7 @@ import com.ruleup.challenge.domain.entity.DraftResult import com.ruleup.challenge.domain.entity.ModerationState import com.ruleup.challenge.domain.entity.MyChallengeSummary import com.ruleup.challenge.domain.entity.RecommendationRateLimitedException +import com.ruleup.challenge.domain.entity.RoutineTemplate import com.ruleup.challenge.domain.entity.VerificationConfig import com.ruleup.challenge.domain.entity.VerificationMethod import com.ruleup.challenge.domain.entity.VerificationType @@ -43,6 +44,7 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -387,6 +389,81 @@ class CreateChallengeViewModelTest { ), ) + @Test + fun `다른 추천을 누르면 지금 보던 추천을 빼 달라고 다시 묻는다`() = + runTest { + val repo = FakeChallengeRepository(templates = { exclude -> if (exclude.isEmpty()) templates(1, 2, 3) else templates(4, 5, 6) }) + val vm = viewModel(repo = repo) + + vm.onIntent(CreateChallengeIntent.Load) + vm.onIntent(CreateChallengeIntent.RefreshTemplates) + + assertEquals(listOf(emptySet(), setOf(1L, 2L, 3L)), repo.templateExcludes) + assertEquals( + listOf(4L, 5L, 6L), + vm.uiState.value.templates + .map { it.templateId }, + ) + } + + @Test + fun `다른 추천을 거듭 받으면 그동안 본 추천을 모두 뺀다`() = + runTest { + var next = 1L + val repo = FakeChallengeRepository(templates = { templates(next++, next++, next++) }) + val vm = viewModel(repo = repo) + + vm.onIntent(CreateChallengeIntent.Load) + vm.onIntent(CreateChallengeIntent.RefreshTemplates) + vm.onIntent(CreateChallengeIntent.RefreshTemplates) + + assertEquals(setOf(1L, 2L, 3L, 4L, 5L, 6L), repo.templateExcludes.last()) + } + + @Test + fun `이미 본 추천만 돌아오면 처음부터 다시 센다`() = + runTest { + // 서버가 exclude 를 아직 모르거나 추천을 다 돌았을 때 — 제외 목록이 끝없이 쌓이지 않게 한다. + val repo = FakeChallengeRepository(templates = { templates(1, 2, 3) }) + val vm = viewModel(repo = repo) + + vm.onIntent(CreateChallengeIntent.Load) + vm.onIntent(CreateChallengeIntent.RefreshTemplates) + vm.onIntent(CreateChallengeIntent.RefreshTemplates) + + assertEquals(setOf(1L, 2L, 3L), repo.templateExcludes.last()) + } + + @Test + fun `다른 추천을 못 받으면 보던 추천을 그대로 둔다`() = + runTest { + val repo = FakeChallengeRepository(templates = { exclude -> if (exclude.isEmpty()) templates(1, 2, 3) else error("추천 실패") }) + val vm = viewModel(repo = repo) + + vm.onIntent(CreateChallengeIntent.Load) + vm.onIntent(CreateChallengeIntent.RefreshTemplates) + + assertEquals( + listOf(1L, 2L, 3L), + vm.uiState.value.templates + .map { it.templateId }, + ) + assertFalse(vm.uiState.value.templatesFailed) + assertFalse(vm.uiState.value.isLoadingTemplates) + } + + private fun templates(vararg ids: Long) = + ids.map { id -> + RoutineTemplate( + templateId = id, + title = "추천 $id", + description = null, + category = Category.EXERCISE, + verificationType = VerificationType.AUTO, + reason = "전체 인기", + ) + } + private fun viewModel( repo: FakeChallengeRepository = FakeChallengeRepository(), nav: RecordingNavigationHelper = RecordingNavigationHelper(),