diff --git a/home/presentation/src/main/kotlin/com/ruleup/home/presentation/HomeScreen.kt b/home/presentation/src/main/kotlin/com/ruleup/home/presentation/HomeScreen.kt index 5db43348..962f618b 100644 --- a/home/presentation/src/main/kotlin/com/ruleup/home/presentation/HomeScreen.kt +++ b/home/presentation/src/main/kotlin/com/ruleup/home/presentation/HomeScreen.kt @@ -53,9 +53,12 @@ import com.ruleup.designsystem.singleClickable import com.ruleup.designsystem.theme.RuleUpTheme import com.ruleup.domain.entity.category.Category import com.ruleup.domain.time.ServiceDate +import com.ruleup.home.presentation.viewmodel.DayResult import com.ruleup.home.presentation.viewmodel.HomeIntent import com.ruleup.home.presentation.viewmodel.HomeState import com.ruleup.home.presentation.viewmodel.HomeViewModel +import com.ruleup.home.presentation.viewmodel.SelectedDay +import com.ruleup.profile.domain.entity.CalendarDayItem import com.ruleup.profile.domain.entity.CalendarDayStatus import com.ruleup.tti.presentation.TtiScreenEffect import com.ruleup.tti.presentation.rememberTtiLargeContent @@ -143,7 +146,18 @@ internal fun HomeContent( verticalArrangement = Arrangement.spacedBy(14.dp), ) { item { header() } - item { WeekPills(statuses = state.weekStatuses) } + item { + WeekStamps( + statuses = state.weekStatuses, + selectedDate = state.selectedDay?.date, + onSelect = { onIntent(HomeIntent.SelectDay(it)) }, + ) + } + val selected = state.selectedDay + if (selected != null) { + item { DayResultSection(selected = selected, onIntent = onIntent) } + return@LazyColumn + } if (hero != null) { item { SectionTitle(title = "오늘 해 볼까요?", trailing = "직접 체크") } item { HeroCard(card = hero, onOpen = { open(hero) }) } @@ -484,9 +498,13 @@ private fun SectionTitle( } } -/** 이번 주 요일 알약. 점은 그날 판정. */ +/** 이번 주 도장. ✓ 그날 다 지킴 · ✕ 하나라도 못 지킴 · 테두리 오늘. 지난 날을 누르면 그날 결과를 본다. */ @Composable -private fun WeekPills(statuses: Map) { +private fun WeekStamps( + statuses: Map, + selectedDate: String?, + onSelect: (String) -> Unit, +) { val today = com.ruleup.ui.time .rememberServiceDate() @@ -494,55 +512,234 @@ private fun WeekPills(statuses: Map) { val labels = listOf("월", "화", "수", "목", "금", "토", "일") Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.SpaceBetween, ) { for (i in 0..6) { val date = monday.plusDays(i.toLong()) - val isToday = date == today + val stamp = dayStamp(date, today, statuses[date.toString()]) + // 아무것도 고르지 않았으면 오늘이 선택된 칸이다. + val selected = (selectedDate ?: today.toString()) == date.toString() Column( modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(16.dp)) - .background(if (isToday) RuleUpTheme.colors.textPrimary else RuleUpTheme.colors.surface.copy(alpha = 0.75f)) - .padding(vertical = 8.dp), + .clip(RoundedCornerShape(14.dp)) + .then(if (stamp == DayStamp.FUTURE) Modifier else Modifier.singleClickable { onSelect(date.toString()) }) + .padding(horizontal = 2.dp, vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( text = labels[i], - color = if (isToday) Color.White.copy(alpha = 0.7f) else RuleUpTheme.colors.textMuted, - style = RuleUpTheme.typography.micro, - ) - Text( - text = date.dayOfMonth.toString(), - color = if (isToday) Color.White else RuleUpTheme.colors.textPrimary, - style = RuleUpTheme.typography.bodyBold, + color = if (selected) RuleUpTheme.colors.brand else RuleUpTheme.colors.textMuted, + style = if (selected) RuleUpTheme.typography.captionBold else RuleUpTheme.typography.caption, ) + DayStampMark(stamp = stamp, dayOfMonth = date.dayOfMonth, selected = selected) + } + } + } +} + +@Composable +private fun DayStampMark( + stamp: DayStamp, + dayOfMonth: Int, + selected: Boolean, +) { + val colors = RuleUpTheme.colors + // 고른 칸은 바깥 고리로 띄운다. + Box( + modifier = + Modifier + .size(42.dp) + .border(2.dp, if (selected) colors.brand else Color.Transparent, CircleShape) + .padding(4.dp), + contentAlignment = Alignment.Center, + ) { + when (stamp) { + DayStamp.DONE, DayStamp.FAILED -> Box( modifier = Modifier - .size(5.dp) + .size(34.dp) .clip(CircleShape) - .background(dayDotColor(date, today, statuses[date.toString()])), - ) + .background(if (stamp == DayStamp.DONE) colors.success else colors.danger), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(if (stamp == DayStamp.DONE) R.drawable.ic_check else R.drawable.ic_close), + contentDescription = if (stamp == DayStamp.DONE) "${dayOfMonth}일 다 지켰어요" else "${dayOfMonth}일 못 지킨 루틴이 있어요", + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + } + DayStamp.TODAY, DayStamp.EMPTY, DayStamp.FUTURE -> + Box( + modifier = + Modifier + .size(34.dp) + .clip(CircleShape) + .background(if (stamp == DayStamp.TODAY) colors.surface else colors.surfaceVariant) + .border(if (stamp == DayStamp.TODAY) 2.dp else 0.dp, colors.brand, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = dayOfMonth.toString(), + color = + when (stamp) { + DayStamp.TODAY -> colors.brand + DayStamp.FUTURE -> colors.textMuted + else -> colors.textSecondary + }, + style = RuleUpTheme.typography.smallBold, + ) + } + } + } +} + +/** 고른 날의 결과. 홈의 히어로 · 매일 루틴 · 주 N회 자리를 대신한다. */ +@Composable +private fun DayResultSection( + selected: SelectedDay, + onIntent: (HomeIntent) -> Unit, +) { + val date = remember(selected.date) { LocalDate.parse(selected.date) } + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = date.headerLabel(), + color = RuleUpTheme.colors.textPrimary, + style = RuleUpTheme.typography.cardTitle, + modifier = Modifier.weight(1f), + ) + Text( + text = "오늘로", + color = RuleUpTheme.colors.brand, + style = RuleUpTheme.typography.smallBold, + modifier = + Modifier + .clip(RoundedCornerShape(99.dp)) + .singleClickable { onIntent(HomeIntent.SelectDay(selected.date)) } + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + when (val result = selected.result) { + DayResult.Loading -> + Box(Modifier.fillMaxWidth().padding(vertical = 32.dp), contentAlignment = Alignment.Center) { + androidx.compose.material3.CircularProgressIndicator(color = RuleUpTheme.colors.brand) + } + DayResult.Failed -> + DayMessage(text = "이날 기록을 불러오지 못했어요", action = "다시 시도", onAction = { onIntent(HomeIntent.RetryDay) }) + is DayResult.Loaded -> { + val groups = result.items.groupByResult() + if (result.items.isEmpty()) { + DayMessage(text = "이날은 판정할 루틴이 없었어요") + } else { + val open: (String) -> Unit = { onIntent(HomeIntent.OpenChallenge(it)) } + DayGroup(title = "수행한 루틴", items = groups.done, done = true, onOpen = open) + DayGroup(title = "실패한 루틴", items = groups.failed, done = false, onOpen = open) + DayGroup(title = "판정 중", items = groups.pending, done = null, onOpen = open) + } } } } } -/** 지난 날만 점을 찍는다 — 전부 성공은 초록, 나머지는 빨강. 오늘·아직 안 온 날·판정 대상이 아닌 날은 비운다. */ @Composable -private fun dayDotColor( - date: LocalDate, - today: LocalDate, - status: CalendarDayStatus?, -): Color = - when { - !date.isBefore(today) || status == null -> Color.Transparent - status == CalendarDayStatus.ALL_DONE -> RuleUpTheme.colors.success - else -> RuleUpTheme.colors.danger +private fun DayMessage( + text: String, + action: String? = null, + onAction: () -> Unit = {}, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = text, color = RuleUpTheme.colors.textSecondary, style = RuleUpTheme.typography.body) + action?.let { + Text( + text = it, + color = RuleUpTheme.colors.brand, + style = RuleUpTheme.typography.bodyBold, + modifier = Modifier.singleClickable(onClick = onAction), + ) + } + } +} + +/** done: true 수행 · false 실패 · null 판정 중. 비어 있으면 그리지 않는다. */ +@Composable +private fun DayGroup( + title: String, + items: List, + done: Boolean?, + onOpen: (String) -> Unit, +) { + if (items.isEmpty()) return + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + SectionTitle(title = "$title ${items.size}") + val shape = RoundedCornerShape(20.dp) + Column( + modifier = + Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .clip(shape) + .background(RuleUpTheme.colors.surface) + .border(1.dp, RuleUpTheme.colors.border, shape) + .padding(vertical = 4.dp), + ) { + items.forEach { item -> + Row( + modifier = + Modifier + .fillMaxWidth() + .singleClickable { onOpen(item.challengeId) } + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CategoryIconTile(category = item.category, size = 36.dp) + Text( + text = item.title, + color = RuleUpTheme.colors.textPrimary, + style = RuleUpTheme.typography.bodyBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (done != null) { + Box( + modifier = + Modifier + .size(24.dp) + .clip(CircleShape) + .background(if (done) RuleUpTheme.colors.success else RuleUpTheme.colors.danger), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(if (done) R.drawable.ic_check else R.drawable.ic_close), + contentDescription = if (done) "수행" else "실패", + tint = Color.White, + modifier = Modifier.size(14.dp), + ) + } + } + } + } + } } +} + +/** 「10월 5일 월요일」. */ +private fun LocalDate.headerLabel(): String { + val weekday = listOf("월", "화", "수", "목", "금", "토", "일")[dayOfWeek.ordinal] + return "${monthValue}월 ${dayOfMonth}일 ${weekday}요일" +} /** 오늘 직접 체크해 볼 만한 챌린지 하나. 대표 사진이 없으면 카테고리 기본 커버. */ @Composable diff --git a/home/presentation/src/main/kotlin/com/ruleup/home/presentation/HomeSections.kt b/home/presentation/src/main/kotlin/com/ruleup/home/presentation/HomeSections.kt index e202a6d0..77b4373c 100644 --- a/home/presentation/src/main/kotlin/com/ruleup/home/presentation/HomeSections.kt +++ b/home/presentation/src/main/kotlin/com/ruleup/home/presentation/HomeSections.kt @@ -3,7 +3,11 @@ package com.ruleup.home.presentation import com.ruleup.challenge.domain.entity.ChallengeLimits import com.ruleup.challenge.domain.entity.TrendingChallenge import com.ruleup.domain.entity.category.Category +import com.ruleup.profile.domain.entity.CalendarDayItem +import com.ruleup.profile.domain.entity.CalendarDayStatus +import com.ruleup.profile.domain.entity.DayItemStatus import com.ruleup.verification.domain.entity.TodayStatus +import java.time.LocalDate /** 매일 해야 하는 루틴인가(주 7회). 횟수를 모르면 매일로 본다 — 주 N회로 접으면 「오늘」 목록에서 빠져 놓친다. */ val HomeChallengeUi.isDaily: Boolean @@ -66,3 +70,52 @@ fun pickStarters( private const val STARTER_LIMIT = 6 private val CLOSED_TODAY = setOf(TodayStatus.DONE, TodayStatus.FAILED) + +/** 주간 도장 한 칸. */ +enum class DayStamp { + // 그날 판정 대상을 전부 지켰다 + DONE, + + // 하나라도 못 지켰다(일부 성공 포함) + FAILED, + + // 오늘, 아직 판정 전 + TODAY, + + // 지난 날인데 판정할 루틴이 없었다 + EMPTY, + + // 아직 오지 않은 날 + FUTURE, +} + +/** 일부만 지킨 날(PARTIAL)도 ✕ 로 본다 — ✓ 는 그날을 다 지켰을 때만. */ +fun dayStamp( + date: LocalDate, + today: LocalDate, + status: CalendarDayStatus?, +): DayStamp = + when { + date.isAfter(today) -> DayStamp.FUTURE + status == CalendarDayStatus.ALL_DONE -> DayStamp.DONE + status in FAILED_DAY -> DayStamp.FAILED + date == today -> DayStamp.TODAY + else -> DayStamp.EMPTY + } + +/** 그날 결과를 수행 · 실패 · 판정 중으로 가른다. */ +data class DayResultGroups( + val done: List, + val failed: List, + // 아직 판정 전이거나 모르는 상태 — 수행 · 실패 어느 쪽으로도 접지 않는다 + val pending: List, +) + +fun List.groupByResult(): DayResultGroups = + DayResultGroups( + done = filter { it.status == DayItemStatus.DONE }, + failed = filter { it.status == DayItemStatus.FAILED || it.status == DayItemStatus.FAIL_EXPECTED }, + pending = filter { it.status == DayItemStatus.IN_PROGRESS || it.status == null }, + ) + +private val FAILED_DAY = setOf(CalendarDayStatus.FAILED, CalendarDayStatus.PARTIAL, CalendarDayStatus.FAIL_EXPECTED) diff --git a/home/presentation/src/main/kotlin/com/ruleup/home/presentation/viewmodel/HomeContract.kt b/home/presentation/src/main/kotlin/com/ruleup/home/presentation/viewmodel/HomeContract.kt index fb2dccd6..c3123d1c 100644 --- a/home/presentation/src/main/kotlin/com/ruleup/home/presentation/viewmodel/HomeContract.kt +++ b/home/presentation/src/main/kotlin/com/ruleup/home/presentation/viewmodel/HomeContract.kt @@ -4,6 +4,7 @@ import com.ruleup.challenge.domain.entity.TrendingChallenge import com.ruleup.domain.entity.category.Category import com.ruleup.home.presentation.HomeChallengeUi import com.ruleup.home.presentation.pickHero +import com.ruleup.profile.domain.entity.CalendarDayItem import com.ruleup.profile.domain.entity.CalendarDayStatus import com.ruleup.ui.mvi.MviIntent import com.ruleup.ui.mvi.ReducerEvent @@ -33,6 +34,30 @@ sealed interface HomeIntent : MviIntent { data class OpenCategory( val category: Category, ) : HomeIntent + + /** 주간 도장을 눌러 그날 결과를 본다. 오늘이나 이미 고른 날을 누르면 원래 홈으로. */ + data class SelectDay( + val date: String, + ) : HomeIntent + + data object RetryDay : HomeIntent +} + +/** 고른 날의 결과. */ +data class SelectedDay( + // YYYY-MM-DD + val date: String, + val result: DayResult, +) + +sealed interface DayResult { + data object Loading : DayResult + + data class Loaded( + val items: List, + ) : DayResult + + data object Failed : DayResult } data class HomeState( @@ -49,6 +74,8 @@ data class HomeState( /** 챌린지가 없는 사람에게 보여 줄 첫 챌린지. 못 받으면 비어 있고 기본 안내를 띄운다. */ val starters: List = emptyList(), val interests: List = emptyList(), + // null 이면 오늘 — 히어로 · 매일 루틴 · 주 N회를 보여 준다 + val selectedDay: SelectedDay? = null, ) : UiState { /** 「오늘 해 볼까요?」에 올릴 챌린지. */ val hero: HomeChallengeUi? @@ -87,6 +114,19 @@ sealed interface HomeReducerEvent : ReducerEvent { val manualCheckable: Map, ) : HomeReducerEvent + data class DaySelected( + val date: String?, + ) : HomeReducerEvent + + data class DayLoaded( + val date: String, + val items: List, + ) : HomeReducerEvent + + data class DayFailed( + val date: String, + ) : HomeReducerEvent + data class StartersLoaded( val starters: List, val interests: List, diff --git a/home/presentation/src/main/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModel.kt b/home/presentation/src/main/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModel.kt index 8bf728f6..4f9a38b0 100644 --- a/home/presentation/src/main/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModel.kt +++ b/home/presentation/src/main/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModel.kt @@ -77,6 +77,14 @@ class HomeViewModel navigationHelper.navigateByRoute(ChallengeDetailPage(intent.challengeId).toRoute()) } + is HomeIntent.SelectDay -> { + selectDay(intent.date) + } + + HomeIntent.RetryDay -> { + currentState.selectedDay?.let { loadDay(it.date) } + } + is HomeIntent.OpenCategory -> { navigationHelper.navigateByRoute(ChallengeExploreListPage(category = intent.category).toRoute()) } @@ -115,6 +123,31 @@ class HomeViewModel state.copy(manualCheckable = state.manualCheckable + event.manualCheckable) } + is HomeReducerEvent.DaySelected -> { + state.copy(selectedDay = event.date?.let { SelectedDay(it, DayResult.Loading) }) + } + + // 응답이 늦게 와도 그사이 다른 날을 골랐으면 덮어쓰지 않는다. + is HomeReducerEvent.DayLoaded -> { + if (state.selectedDay?.date != + event.date + ) { + state + } else { + state.copy(selectedDay = SelectedDay(event.date, DayResult.Loaded(event.items))) + } + } + + is HomeReducerEvent.DayFailed -> { + if (state.selectedDay?.date != + event.date + ) { + state + } else { + state.copy(selectedDay = SelectedDay(event.date, DayResult.Failed)) + } + } + is HomeReducerEvent.StartersLoaded -> { state.copy(starters = event.starters, interests = event.interests) } @@ -175,6 +208,24 @@ class HomeViewModel dispatch(HomeReducerEvent.StartersLoaded(pickStarters(trending, interests), interests)) } + private fun selectDay(date: String) { + val backToToday = date == ServiceDate.today().toString() || date == currentState.selectedDay?.date + if (backToToday) { + dispatch(HomeReducerEvent.DaySelected(null)) + return + } + loadDay(date) + } + + private fun loadDay(date: String) { + dispatch(HomeReducerEvent.DaySelected(date)) + viewModelScope.launch { + runCatching { myPageRepository.getCalendarDay(date).items } + .onSuccess { dispatch(HomeReducerEvent.DayLoaded(date, it)) } + .onFailure { dispatch(HomeReducerEvent.DayFailed(date)) } + } + } + /** 레드닷용 미읽음 확인. */ private fun checkUnread() { viewModelScope.launch { diff --git a/home/presentation/src/test/kotlin/com/ruleup/home/presentation/HomeContentTest.kt b/home/presentation/src/test/kotlin/com/ruleup/home/presentation/HomeContentTest.kt index 58a9d4e8..27e25038 100644 --- a/home/presentation/src/test/kotlin/com/ruleup/home/presentation/HomeContentTest.kt +++ b/home/presentation/src/test/kotlin/com/ruleup/home/presentation/HomeContentTest.kt @@ -9,9 +9,14 @@ import com.ruleup.challenge.domain.entity.VerificationType import com.ruleup.designsystem.theme.RuleUpTheme import com.ruleup.domain.entity.category.Category import com.ruleup.domain.test.ClickClock +import com.ruleup.domain.time.ServiceDate +import com.ruleup.home.presentation.viewmodel.DayResult import com.ruleup.home.presentation.viewmodel.HomeIntent import com.ruleup.home.presentation.viewmodel.HomeState +import com.ruleup.home.presentation.viewmodel.SelectedDay import com.ruleup.observability.domain.test.testObservability +import com.ruleup.profile.domain.entity.CalendarDayItem +import com.ruleup.profile.domain.entity.DayItemStatus import com.ruleup.ui.helper.LocalObservability import org.junit.Rule import org.junit.Test @@ -161,6 +166,33 @@ class HomeContentTest { endDate = null, ) + @Test + fun `고른 날의 결과가 오면 매일 루틴 대신 수행한 루틴과 실패한 루틴을 보여 준다`() { + val done = dayItem("d1", "아침 6시 기상", DayItemStatus.DONE) + val failed = dayItem("f1", "밤 11시 이후 폰 끄기", DayItemStatus.FAILED) + val date = ServiceDate.today().minusDays(1).toString() + render(state(card("ch1")).copy(selectedDay = SelectedDay(date, DayResult.Loaded(listOf(done, failed))))) + + compose.onNodeWithText("수행한 루틴 1").assertExists() + compose.onNodeWithText("실패한 루틴 1").assertExists() + compose.onNodeWithText("밤 11시 이후 폰 끄기").assertExists() + compose.onNodeWithText("오늘 · 매일 루틴").assertDoesNotExist() + } + + @Test + fun `고른 날에 판정할 루틴이 없었으면 그렇게 알려 준다`() { + val date = ServiceDate.today().minusDays(1).toString() + render(state(card("ch1")).copy(selectedDay = SelectedDay(date, DayResult.Loaded(emptyList())))) + + compose.onNodeWithText("이날은 판정할 루틴이 없었어요").assertExists() + } + + private fun dayItem( + id: String, + title: String, + status: DayItemStatus, + ) = CalendarDayItem(id, title, null, status, null, null, null, null, null) + private fun state(vararg cards: HomeChallengeUi) = HomeState(isLoading = false, challenges = cards.toList()) private fun card( diff --git a/home/presentation/src/test/kotlin/com/ruleup/home/presentation/HomeSectionsTest.kt b/home/presentation/src/test/kotlin/com/ruleup/home/presentation/HomeSectionsTest.kt index 2617ec61..daf52fd4 100644 --- a/home/presentation/src/test/kotlin/com/ruleup/home/presentation/HomeSectionsTest.kt +++ b/home/presentation/src/test/kotlin/com/ruleup/home/presentation/HomeSectionsTest.kt @@ -3,6 +3,9 @@ package com.ruleup.home.presentation import com.ruleup.challenge.domain.entity.TrendingChallenge import com.ruleup.challenge.domain.entity.VerificationType import com.ruleup.domain.entity.category.Category +import com.ruleup.profile.domain.entity.CalendarDayItem +import com.ruleup.profile.domain.entity.CalendarDayStatus +import com.ruleup.profile.domain.entity.DayItemStatus import com.ruleup.verification.domain.entity.TodayStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -116,6 +119,62 @@ class HomeSectionsTest { assertEquals(6, pickStarters(trending, interests = emptyList()).size) } + @Test + fun `그날을 다 지켰을 때만 체크 도장이다`() { + assertEquals(DayStamp.DONE, dayStamp(MON, TUE, CalendarDayStatus.ALL_DONE)) + } + + @Test + fun `일부만 지킨 날도 X 도장이다`() { + // 하나라도 못 지켰으면 그날은 못 지킨 날로 보인다(사용자 결정 #596). + assertEquals(DayStamp.FAILED, dayStamp(MON, TUE, CalendarDayStatus.PARTIAL)) + assertEquals(DayStamp.FAILED, dayStamp(MON, TUE, CalendarDayStatus.FAILED)) + } + + @Test + fun `오늘은 판정 전이면 테두리 칸이고 다 지키면 체크다`() { + assertEquals(DayStamp.TODAY, dayStamp(TUE, TUE, CalendarDayStatus.IN_PROGRESS)) + assertEquals(DayStamp.TODAY, dayStamp(TUE, TUE, null)) + assertEquals(DayStamp.DONE, dayStamp(TUE, TUE, CalendarDayStatus.ALL_DONE)) + } + + @Test + fun `판정할 루틴이 없던 지난 날과 앞으로 올 날은 도장을 찍지 않는다`() { + assertEquals(DayStamp.EMPTY, dayStamp(MON, TUE, null)) + assertEquals(DayStamp.FUTURE, dayStamp(TUE.plusDays(1), TUE, CalendarDayStatus.ALL_DONE)) + } + + @Test + fun `그날 결과는 수행 실패 판정 중으로 갈리고 판정 전은 어느 쪽으로도 접지 않는다`() { + val groups = + listOf( + dayItem("done", DayItemStatus.DONE), + dayItem("failed", DayItemStatus.FAILED), + dayItem("expected", DayItemStatus.FAIL_EXPECTED), + dayItem("progress", DayItemStatus.IN_PROGRESS), + dayItem("unknown", null), + ).groupByResult() + + assertEquals(listOf("done"), groups.done.map { it.challengeId }) + assertEquals(listOf("failed", "expected"), groups.failed.map { it.challengeId }) + assertEquals(listOf("progress", "unknown"), groups.pending.map { it.challengeId }) + } + + private fun dayItem( + id: String, + status: DayItemStatus?, + ) = CalendarDayItem( + challengeId = id, + title = "챌린지 $id", + category = null, + status = status, + verificationId = null, + verifiedVia = null, + confirmedAt = null, + failureReason = null, + appeal = null, + ) + private fun trend( id: String, category: Category?, @@ -151,4 +210,9 @@ class HomeSectionsTest { imageUrl = null, active = active, ) + + private companion object { + val MON: java.time.LocalDate = java.time.LocalDate.of(2026, 10, 5) + val TUE: java.time.LocalDate = java.time.LocalDate.of(2026, 10, 6) + } } diff --git a/home/presentation/src/test/kotlin/com/ruleup/home/presentation/viewmodel/FakeMyPageRepository.kt b/home/presentation/src/test/kotlin/com/ruleup/home/presentation/viewmodel/FakeMyPageRepository.kt index 76eaac92..a226cdb5 100644 --- a/home/presentation/src/test/kotlin/com/ruleup/home/presentation/viewmodel/FakeMyPageRepository.kt +++ b/home/presentation/src/test/kotlin/com/ruleup/home/presentation/viewmodel/FakeMyPageRepository.kt @@ -13,6 +13,7 @@ import com.ruleup.profile.domain.repository.MyPageRepository /** 홈은 이번 주 캘린더만 읽는다. */ internal class FakeMyPageRepository( + private val day: (date: String) -> CalendarDayDetail = { CalendarDayDetail(date = it, items = emptyList()) }, private val calendar: (month: String) -> ActivityCalendar = { ActivityCalendar(month = it, days = emptyList()) }, ) : MyPageRepository { val requestedMonths = mutableListOf() @@ -32,7 +33,7 @@ internal class FakeMyPageRepository( override suspend fun getScoreChanges(cursor: String?): ScoreChangePage = error("홈에서 쓰지 않는다") - override suspend fun getCalendarDay(date: String): CalendarDayDetail = error("홈에서 쓰지 않는다") + override suspend fun getCalendarDay(date: String): CalendarDayDetail = day(date) override suspend fun getStats(): StatsReport = error("홈에서 쓰지 않는다") diff --git a/home/presentation/src/test/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModelTest.kt b/home/presentation/src/test/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModelTest.kt index 915da52d..7858c83e 100644 --- a/home/presentation/src/test/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModelTest.kt +++ b/home/presentation/src/test/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModelTest.kt @@ -24,7 +24,10 @@ import com.ruleup.domain.time.ServiceDate import com.ruleup.notification.domain.fake.FakeNotificationRepository import com.ruleup.profile.domain.entity.ActivityCalendar import com.ruleup.profile.domain.entity.CalendarDay +import com.ruleup.profile.domain.entity.CalendarDayDetail +import com.ruleup.profile.domain.entity.CalendarDayItem import com.ruleup.profile.domain.entity.CalendarDayStatus +import com.ruleup.profile.domain.entity.DayItemStatus import com.ruleup.verification.domain.entity.ChallengeProgress import com.ruleup.verification.domain.entity.ProgressSnapshot import com.ruleup.verification.domain.entity.TodayStatus @@ -298,6 +301,56 @@ class HomeViewModelTest { assertEquals(Category.READING.value, nav.routes.single().args["category"]) } + @Test + fun `지난 날을 고르면 그날 결과를 불러온다`() = + runTest { + val date = ServiceDate.today().minusDays(1).toString() + val item = CalendarDayItem("ch1", "아침 6시 기상", null, DayItemStatus.DONE, null, null, null, null, null) + val vm = viewModel(myPage = FakeMyPageRepository(day = { CalendarDayDetail(it, listOf(item)) })) + + vm.onIntent(HomeIntent.SelectDay(date)) + + assertEquals(SelectedDay(date, DayResult.Loaded(listOf(item))), vm.uiState.value.selectedDay) + } + + @Test + fun `오늘이나 이미 고른 날을 다시 누르면 원래 홈으로 돌아간다`() = + runTest { + val date = ServiceDate.today().minusDays(1).toString() + val vm = viewModel() + + vm.onIntent(HomeIntent.SelectDay(date)) + vm.onIntent(HomeIntent.SelectDay(date)) + assertNull(vm.uiState.value.selectedDay) + + vm.onIntent(HomeIntent.SelectDay(date)) + vm.onIntent(HomeIntent.SelectDay(ServiceDate.today().toString())) + assertNull(vm.uiState.value.selectedDay) + } + + @Test + fun `그날 결과를 못 받으면 실패로 두고 다시 시도할 수 있다`() = + runTest { + var fail = true + val date = ServiceDate.today().minusDays(1).toString() + val vm = viewModel(myPage = FakeMyPageRepository(day = { if (fail) error("상세 실패") else CalendarDayDetail(it, emptyList()) })) + + vm.onIntent(HomeIntent.SelectDay(date)) + assertEquals( + DayResult.Failed, + vm.uiState.value.selectedDay + ?.result, + ) + + fail = false + vm.onIntent(HomeIntent.RetryDay) + assertEquals( + DayResult.Loaded(emptyList()), + vm.uiState.value.selectedDay + ?.result, + ) + } + private fun viewModel( challenges: List? = emptyList(), progress: ProgressSnapshot? = ProgressSnapshot(asOf = "2026-09-01T00:00:00Z", challenges = emptyList()),