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 @@ -19,4 +19,9 @@ class MyChallengeStoreImpl
override fun add(summary: MyChallengeSummary) {
items[summary.challengeId] = summary
}

@Synchronized
override fun remove(challengeId: String) {
items.remove(challengeId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@ interface MyChallengeStore {

/** 생성/참여한 챌린지를 추가(같은 challengeId 면 최신값으로 갱신). */
fun add(summary: MyChallengeSummary)

/** 나간 챌린지를 뺀다. 남겨 두면 서버 목록 조회가 실패했을 때 나간 방이 홈에 되살아난다. */
fun remove(challengeId: String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.ruleup.challenge.domain.fake

import com.ruleup.challenge.domain.entity.MyChallengeSummary
import com.ruleup.challenge.domain.repository.MyChallengeStore

/** 테스트용 인메모리 [MyChallengeStore]. */
class FakeMyChallengeStore(
initial: List<MyChallengeSummary> = emptyList(),
) : MyChallengeStore {
private val items = initial.associateBy { it.challengeId }.toMutableMap()

override fun all(): List<MyChallengeSummary> = items.values.toList()

override fun add(summary: MyChallengeSummary) {
items[summary.challengeId] = summary
}

override fun remove(challengeId: String) {
items.remove(challengeId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.ruleup.designsystem.theme.RuleUpTheme

/** 인증 방법 안내(서버가 준 verification.guide). 서버가 아직 채우는 중이면 그렇다고 알린다. */
/** 인증 방법 안내(verification.guide, 없으면 템플릿 설명). 둘 다 없으면 서버가 채우는 중이라고 알린다. */
@Composable
internal fun VerificationGuideText(
guide: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,6 @@ internal fun ChallengeDetail.scheduleLabel(): String =
listOfNotNull(
periodLabel(period.start, period.end).substringAfter(" · ", "").takeIf { it.isNotBlank() },
weeklyCount?.let { if (it >= ChallengeLimits.WEEKLY_COUNT_MAX) "매일" else "주 ${it}회" },
verification.detail,
).joinToString(" · ")

/** 방장이 나가면 봇이 자리를 지킨다(owner 가 null 이 된다). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,20 +96,8 @@ internal fun DetailInfoPage(
}
RuleUpCard {
RoomSectionHeader(title = "인증 방법")
Text(
text = if (detail.verification.type.isAuto) "자동 인증" else "직접 체크",
color = RuleUpTheme.colors.brand,
style = RuleUpTheme.typography.smallBold,
modifier =
Modifier
.clip(RoundedCornerShape(99.dp))
.background(RuleUpTheme.colors.brandSoft)
.padding(horizontal = 10.dp, vertical = 4.dp),
)
detail.verification.detail?.let {
Text(text = it, color = RuleUpTheme.colors.textSlate, style = RuleUpTheme.typography.body)
}
VerificationGuideText(guide = detail.verification.guide)
VerificationTypeChip(isAuto = detail.verification.type.isAuto)
VerificationGuideText(guide = detail.verification.guide ?: detail.verification.detail)
// 가입 전에 어떤 권한을 내줘야 하는지 알게 한다
RequiredPermissionList(tokens = requiredPermissions, snapshot = permissions)
}
Expand Down Expand Up @@ -167,6 +155,21 @@ internal fun DetailInfoPage(
}
}

/** 「자동 인증」·「직접 체크」 칩. */
@Composable
internal fun VerificationTypeChip(isAuto: Boolean) {
Text(
text = if (isAuto) "자동 인증" else "직접 체크",
color = RuleUpTheme.colors.brand,
style = RuleUpTheme.typography.smallBold,
modifier =
Modifier
.clip(RoundedCornerShape(99.dp))
.background(RuleUpTheme.colors.brandSoft)
.padding(horizontal = 10.dp, vertical = 4.dp),
)
}

@Composable
private fun InfoRow(
label: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -417,14 +417,8 @@ internal fun VerificationRuleCard(
) {
RuleUpCard {
RoomSectionHeader(title = "인증 규칙")
Text(
text =
detail.verification.detail
?: if (detail.verification.type.isAuto) "자동 인증" else "직접 체크",
color = RuleUpTheme.colors.textSlate,
style = RuleUpTheme.typography.body,
)
VerificationGuideText(guide = detail.verification.guide)
VerificationTypeChip(isAuto = detail.verification.type.isAuto)
VerificationGuideText(guide = detail.verification.guide ?: detail.verification.detail)
RequiredPermissionList(tokens = requiredPermissions, snapshot = permissions)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.ruleup.challenge.domain.navigation.ChallengeWatchersPage
import com.ruleup.challenge.domain.navigation.MyChallengesPage
import com.ruleup.challenge.domain.repository.ChallengeRepository
import com.ruleup.challenge.domain.repository.ExploreRepository
import com.ruleup.challenge.domain.repository.MyChallengeStore
import com.ruleup.challenge.domain.repository.RoomRepository
import com.ruleup.challenge.domain.repository.TargetAppStore
import com.ruleup.challenge.domain.repository.WatcherRepository
Expand Down Expand Up @@ -75,6 +76,7 @@ class ChallengeDetailViewModel
private val navigationHelper: NavigationHelper,
private val checkVerificationAccess: CheckVerificationAccessUseCase,
private val agreeVerificationConsent: AgreeVerificationConsentUseCase,
private val myChallengeStore: MyChallengeStore,
private val restrictionProvider: com.ruleup.onboarding.domain.account.AccountRestrictionProvider =
com.ruleup.onboarding.domain.account
.AccountRestrictionProvider {
Expand Down Expand Up @@ -842,6 +844,7 @@ class ChallengeDetailViewModel
dispatch(ChallengeDetailReducerEvent.MemberActionLoading(true))
runCatching { challengeRepository.leaveChallenge(id) }
.onSuccess { result ->
myChallengeStore.remove(id)
emitEffect(
ChallengeDetailEffect.ShowMessage(
if (result.penaltyApplied) "탈퇴했어요. 진행 이력이 있어 탈퇴 패널티가 적용됐어요" else "챌린지에서 나갔어요",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,4 +518,8 @@ private class RecordingChallengeStore : MyChallengeStore {
override fun add(summary: MyChallengeSummary) {
added += summary
}

override fun remove(challengeId: String) {
added.removeAll { it.challengeId == challengeId }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,34 @@ class ChallengeDetailContentTest {
compose.onNodeWithText("꺼짐").assertExists()
}

@Test
fun `인증 방법은 서버 안내 한 줄만 보여 주고 템플릿 설명을 겹쳐 쓰지 않는다`() {
// 둘 다 그리면 같은 규칙이 두 문장으로 보이고, 표지 일정 줄에도 설명이 통째로 붙는다.
val base = detail("평일 아침 헬스장 출석")
val state =
loaded().copy(
detail = base.copy(verification = base.verification.copy(detail = "템플릿 설명", guide = "서버 안내")),
)
render(state)

compose.onNodeWithText("템플릿 설명", substring = true).assertDoesNotExist()
compose.onNodeWithText("상세 내용 보기").clickPastGuard()

compose.onNodeWithText("서버 안내").assertExists()
compose.onNodeWithText("템플릿 설명", substring = true).assertDoesNotExist()
}

@Test
fun `서버 안내가 없으면 템플릿 설명으로 인증 방법을 채운다`() {
val base = detail("평일 아침 헬스장 출석")
render(loaded().copy(detail = base.copy(verification = base.verification.copy(detail = "템플릿 설명", guide = null))))

compose.onNodeWithText("상세 내용 보기").clickPastGuard()

compose.onNodeWithText("템플릿 설명").assertExists()
compose.onNodeWithText("아직 입력중입니다.").assertDoesNotExist()
}

@Test
fun `티어가 모자라 못 들어가면 필요한 티어와 내 티어를 한글 이름으로 보여 준다`() {
// enum 값을 그대로 넘기면 「SILVER 티어부터」처럼 영문이 보인다(#591).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.ruleup.challenge.domain.entity.VerificationConfig
import com.ruleup.challenge.domain.entity.VerificationMethod
import com.ruleup.challenge.domain.entity.VerificationType
import com.ruleup.challenge.domain.fake.FakeChallengeRepository
import com.ruleup.challenge.domain.fake.FakeMyChallengeStore
import com.ruleup.challenge.domain.fake.FakeWatcherRepository
import com.ruleup.challenge.presentation.detail.fake.FakeReportRepository
import com.ruleup.challenge.presentation.fake.FakeAccountRepository
Expand Down Expand Up @@ -360,6 +361,7 @@ class ChallengeDetailJoinTest {
navigationHelper = nav,
checkVerificationAccess = CheckVerificationAccessUseCase(permissionStatus, account),
agreeVerificationConsent = AgreeVerificationConsentUseCase(account, FakeIntroRepository()),
myChallengeStore = FakeMyChallengeStore(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.ruleup.challenge.domain.entity.VerificationConfig
import com.ruleup.challenge.domain.entity.VerificationMethod
import com.ruleup.challenge.domain.entity.VerificationType
import com.ruleup.challenge.domain.fake.FakeChallengeRepository
import com.ruleup.challenge.domain.fake.FakeMyChallengeStore
import com.ruleup.challenge.domain.fake.FakeWatcherRepository
import com.ruleup.challenge.presentation.detail.fake.FakeReportRepository
import com.ruleup.challenge.presentation.fake.FakeAccountRepository
Expand Down Expand Up @@ -240,6 +241,7 @@ class ChallengeDetailMuteTest {
navigationHelper = nav,
checkVerificationAccess = CheckVerificationAccessUseCase(PermissionStatusProvider { snapshot() }, FakeAccountRepository()),
agreeVerificationConsent = AgreeVerificationConsentUseCase(FakeAccountRepository(), FakeIntroRepository()),
myChallengeStore = FakeMyChallengeStore(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.ruleup.challenge.domain.entity.VerificationConfig
import com.ruleup.challenge.domain.entity.VerificationMethod
import com.ruleup.challenge.domain.entity.VerificationType
import com.ruleup.challenge.domain.fake.FakeChallengeRepository
import com.ruleup.challenge.domain.fake.FakeMyChallengeStore
import com.ruleup.challenge.domain.fake.FakeWatcherRepository
import com.ruleup.challenge.presentation.detail.fake.FakeReportRepository
import com.ruleup.challenge.presentation.fake.FakeAccountRepository
Expand Down Expand Up @@ -316,6 +317,7 @@ class ChallengeDetailReportTest {
navigationHelper = nav,
checkVerificationAccess = CheckVerificationAccessUseCase(PermissionStatusProvider { snapshot() }, FakeAccountRepository()),
agreeVerificationConsent = AgreeVerificationConsentUseCase(FakeAccountRepository(), FakeIntroRepository()),
myChallengeStore = FakeMyChallengeStore(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.ruleup.challenge.domain.entity.VerificationConfig
import com.ruleup.challenge.domain.entity.VerificationMethod
import com.ruleup.challenge.domain.entity.VerificationType
import com.ruleup.challenge.domain.fake.FakeChallengeRepository
import com.ruleup.challenge.domain.fake.FakeMyChallengeStore
import com.ruleup.challenge.domain.fake.FakeWatcherRepository
import com.ruleup.challenge.presentation.detail.fake.FakeReportRepository
import com.ruleup.challenge.presentation.fake.FakeAccountRepository
Expand Down Expand Up @@ -151,5 +152,6 @@ class ChallengeDetailRoomLoadTest {
navigationHelper = RecordingNavigationHelper(),
checkVerificationAccess = CheckVerificationAccessUseCase(PermissionStatusProvider { snapshot() }, FakeAccountRepository()),
agreeVerificationConsent = AgreeVerificationConsentUseCase(FakeAccountRepository(), FakeIntroRepository()),
myChallengeStore = FakeMyChallengeStore(),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import com.ruleup.challenge.domain.entity.ChallengeThreads
import com.ruleup.challenge.domain.entity.JoinNote
import com.ruleup.challenge.domain.entity.LeaveResult
import com.ruleup.challenge.domain.entity.MemberRole
import com.ruleup.challenge.domain.entity.MyChallengeSummary
import com.ruleup.challenge.domain.entity.OwnerType
import com.ruleup.challenge.domain.entity.VerificationConfig
import com.ruleup.challenge.domain.entity.VerificationMethod
import com.ruleup.challenge.domain.entity.VerificationType
import com.ruleup.challenge.domain.fake.FakeChallengeRepository
import com.ruleup.challenge.domain.fake.FakeMyChallengeStore
import com.ruleup.challenge.domain.fake.FakeWatcherRepository
import com.ruleup.challenge.domain.navigation.MyChallengesPage
import com.ruleup.challenge.presentation.detail.fake.FakeReportRepository
Expand Down Expand Up @@ -132,6 +134,56 @@ class ChallengeDetailSoloTest {
assertEquals(MyChallengesPage.PATH, nav.replaced.single().path)
}

@Test
fun `탈퇴하면 방금 만든 챌린지 기록에서도 빠진다`() =
runTest {
// 남아 있으면 홈 목록 조회가 실패할 때 나간 방이 다시 그려진다.
val store = FakeMyChallengeStore(listOf(summary(CHALLENGE_ID), summary("other")))
val viewModel =
viewModel(
repo =
FakeChallengeRepository(
detail = { detail(mode = ChallengeMode.SOLO) },
leave = { LeaveResult(penaltyApplied = false) },
),
store = store,
)
viewModel.onIntent(ChallengeDetailIntent.Load(CHALLENGE_ID))

viewModel.onIntent(ChallengeDetailIntent.LeaveChallenge)

assertEquals(listOf("other"), store.all().map { it.challengeId })
}

@Test
fun `탈퇴에 실패하면 방금 만든 챌린지 기록을 지우지 않는다`() =
runTest {
val store = FakeMyChallengeStore(listOf(summary(CHALLENGE_ID)))
val viewModel =
viewModel(
repo =
FakeChallengeRepository(
detail = { detail(mode = ChallengeMode.SOLO) },
leave = { throw IllegalStateException("탈퇴 실패") },
),
store = store,
)
viewModel.onIntent(ChallengeDetailIntent.Load(CHALLENGE_ID))

viewModel.onIntent(ChallengeDetailIntent.LeaveChallenge)

assertEquals(listOf(CHALLENGE_ID), store.all().map { it.challengeId })
}

private fun summary(id: String) =
MyChallengeSummary(
challengeId = id,
title = "방금 만든 챌린지",
category = Category.entries.first(),
mode = ChallengeMode.SOLO,
durationDays = 30,
)

private fun detail(mode: ChallengeMode) =
ChallengeDetail(
title = "매일 걷기",
Expand Down Expand Up @@ -163,6 +215,7 @@ class ChallengeDetailSoloTest {
repo: FakeChallengeRepository,
rooms: FakeRoomRepository = FakeRoomRepository(),
nav: RecordingNavigationHelper = RecordingNavigationHelper(),
store: FakeMyChallengeStore = FakeMyChallengeStore(),
): ChallengeDetailViewModel {
val account = FakeAccountRepository(agreed = AgreementType.entries.toSet())
return ChallengeDetailViewModel(
Expand All @@ -180,6 +233,7 @@ class ChallengeDetailSoloTest {
navigationHelper = nav,
checkVerificationAccess = CheckVerificationAccessUseCase(PermissionStatusProvider { snapshot() }, account),
agreeVerificationConsent = AgreeVerificationConsentUseCase(account, FakeIntroRepository()),
myChallengeStore = store,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,18 @@ data class HomeChallengeUi(
val active: Boolean,
)

/** 서버 "내 챌린지 목록"이 기준이고 진행률이 진행바·오늘 대상 여부를 채운다. */
/**
* 서버 "내 챌린지 목록"이 기준이고 진행률이 진행바·오늘 대상 여부를 채운다.
* [myChallenges] 가 null 이면 목록 조회 실패다 — 그때만 [locals] 로 빈자리를 메운다.
*/
fun mergeHomeChallenges(
myChallenges: List<MyChallenge>,
myChallenges: List<MyChallenge>?,
progress: ProgressSnapshot?,
locals: List<MyChallengeSummary>,
): List<HomeChallengeUi> {
val progressById = progress?.challenges.orEmpty().associateBy { it.challengeId }
val serverCards = myChallenges.map { it.toHomeUi(progressById[it.challengeId]) }
val serverIds = myChallenges.map { it.challengeId }.toSet()
val serverCards = myChallenges.orEmpty().map { it.toHomeUi(progressById[it.challengeId]) }
val serverIds = myChallenges.orEmpty().map { it.challengeId }.toSet()

// 목록 조회 실패 등으로 서버 목록이 비어도 진행률 카드는 유지한다.
val progressOnlyCards =
Expand All @@ -48,10 +51,15 @@ fun mergeHomeChallenges(
.map { it.toHomeUi() }

val coveredIds = serverIds + progressOnlyCards.map { it.challengeId }.toSet()
// 서버 목록을 받았는데 로컬에만 있으면 이미 나간 방이다.
val localCards =
locals
.filter { it.challengeId !in coveredIds }
.map { it.toHomeUi() }
if (myChallenges != null) {
emptyList()
} else {
locals
.filter { it.challengeId !in coveredIds }
.map { it.toHomeUi() }
}
return localCards + serverCards + progressOnlyCards
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ class HomeViewModel
val challenges =
async {
runCatching { challengeRepository.getMyChallenges().challenges }
.getOrDefault(emptyList())
.getOrNull()
}
val progressSnapshot =
async { runCatching { verificationRepository.getProgress() }.getOrNull() }
Expand Down
Loading
Loading