Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<RoutineTemplatesResponse>
suspend fun getRoutineTemplates(
@Query("exclude") exclude: String? = null,
): BaseResponse<RoutineTemplatesResponse>

// 경로 B: 설명 입력 → LLM 5-Step 초안.
@POST("v1/challenges/draft")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ class ChallengeRepositoryImpl
private val api: ChallengeApi,
private val imageReader: ImageReader,
) : ChallengeRepository {
override suspend fun getRoutineTemplates(): List<RoutineTemplate> =
override suspend fun getRoutineTemplates(exclude: Set<Long>): List<RoutineTemplate> =
api
.getRoutineTemplates()
.getRoutineTemplates(exclude = exclude.takeIf { it.isNotEmpty() }?.joinToString(","))
.getOrThrow()
.toDomain()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class FailingChallengeApi(
) : ChallengeApi {
private fun <T> failed(): BaseResponse<T> = BaseResponse(success = false, data = null, error = error)

override suspend fun getRoutineTemplates(): BaseResponse<RoutineTemplatesResponse> = failed()
override suspend fun getRoutineTemplates(exclude: String?): BaseResponse<RoutineTemplatesResponse> = failed()

override suspend fun createDraft(request: DraftRequest): BaseResponse<DraftResponse> = failed()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import com.ruleup.challenge.domain.entity.RoutineDescription
import com.ruleup.challenge.domain.entity.RoutineTemplate

interface ChallengeRepository {
/** 생성 화면에 항상 떠 있는 추천 루틴. */
suspend fun getRoutineTemplates(): List<RoutineTemplate>
/** 생성 화면에 항상 떠 있는 추천 루틴. [exclude] 는 이미 보여 준 템플릿 — 「다른 추천 보기」에서 넘긴다. */
suspend fun getRoutineTemplates(exclude: Set<Long> = emptySet()): List<RoutineTemplate>

/** 루틴 설명으로 초안을 만든다. */
suspend fun createDraft(description: RoutineDescription): DraftResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Long>) -> List<RoutineTemplate>)? = null,
) : ChallengeRepository {
var lastCommand: CreateChallengeCommand? = null
private set
Expand All @@ -65,7 +67,13 @@ class FakeChallengeRepository(
var lastUpdate: ChallengeUpdate? = null
private set

override suspend fun getRoutineTemplates() = throw NotImplementedError()
/** 추천 루틴을 어떤 제외 목록으로 물었는지. */
val templateExcludes = mutableListOf<Set<Long>>()

override suspend fun getRoutineTemplates(exclude: Set<Long>): List<RoutineTemplate> {
templateExcludes += exclude
return templates?.invoke(exclude) ?: throw NotImplementedError()
}

override suspend fun createDraft(description: RoutineDescription): DraftResult {
calls += "createDraft"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) })
Expand Down Expand Up @@ -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),
Expand All @@ -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),
)
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ sealed interface CreateChallengeIntent : MviIntent {
/** 추천 영역만 재시도. */
data object RetryTemplates : CreateChallengeIntent

/** 「다른 추천 보기」. 지금까지 본 추천을 빼고 새로 받는다. */
data object RefreshTemplates : CreateChallengeIntent

// 확인 화면
data class SetTitle(
val title: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ sealed interface CreateChallengeReducerEvent : ReducerEvent {

data class TemplatesLoaded(
val templates: List<RoutineTemplate>,
val seenTemplateIds: Set<Long>,
) : CreateChallengeReducerEvent

data object TemplatesFailed : CreateChallengeReducerEvent

/** 다른 추천을 못 받았다 — 보던 추천은 그대로 둔다. */
data object TemplatesRefreshFailed : CreateChallengeReducerEvent

data object Drafting : CreateChallengeReducerEvent

data object DraftFailed : CreateChallengeReducerEvent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ data class CreateChallengeState(
val isLoadingTemplates: Boolean,
// 추천 영역만 재시도
val templatesFailed: Boolean,
// 「다른 추천 보기」로 지금까지 본 추천 — 다음 요청에서 뺀다
val seenTemplateIds: Set<Long> = emptySet(),
// 확인 화면
val draftId: String?,
val original: ChallengeDraft?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class CreateChallengeViewModel
CreateChallengeIntent.Exit -> exit()

CreateChallengeIntent.RetryTemplates -> loadTemplates()
CreateChallengeIntent.RefreshTemplates -> refreshTemplates()

is CreateChallengeIntent.SetRoutineDescription -> {
savedStateHandle[KEY_ROUTINE_DESCRIPTION] = intent.description
Expand Down Expand Up @@ -213,13 +214,17 @@ class CreateChallengeViewModel
is CreateChallengeReducerEvent.TemplatesLoaded ->
state.copy(
templates = event.templates,
seenTemplateIds = event.seenTemplateIds,
isLoadingTemplates = false,
templatesFailed = false,
)

CreateChallengeReducerEvent.TemplatesFailed ->
state.copy(isLoadingTemplates = false, templatesFailed = true)

CreateChallengeReducerEvent.TemplatesRefreshFailed ->
state.copy(isLoadingTemplates = false)

CreateChallengeReducerEvent.Drafting ->
state.copy(isDrafting = true, fallbackMessage = null)

Expand Down Expand Up @@ -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<Long> = 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("다른 추천을 불러오지 못했어요"))
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
Loading