diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt index ce76864003..12b8d65715 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt @@ -115,12 +115,17 @@ fun HomeScreen( TitleTopBar( title = stringResource(MindRecordR.string.mindrecord_home_title), actions = { - ViewModeSwitcher( - isListView = isListView, - image1 = R.drawable.core_ui_list, - image2 = R.drawable.core_ui_calendar, - onViewChange = { isListView = it }, - ) + // 주간리포트는 리스트/캘린더 두 보기가 없다 — 스위치를 눌러도 화면이 + // 바뀌지 않으니 아예 노출하지 않는다. 컨트롤과 동작을 맞춘다 (#723). + // 같은 이유로 아래 FAB 도 이 탭에서 숨긴다. + if (selectedCategory != MindRecordCategoryUi.WeeklyReport) { + ViewModeSwitcher( + isListView = isListView, + image1 = R.drawable.core_ui_list, + image2 = R.drawable.core_ui_calendar, + onViewChange = { isListView = it }, + ) + } }, ) }, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt index 25ae86fa73..ef2825a073 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -48,7 +49,14 @@ fun WeeklyReportScreen( } is WeeklyReportUiState.Error -> { - ErrorBox(message = state.message.asString(), modifier = modifier) + // 보여 줄 리포트가 없어도 주차 선택과 재시도는 남긴다 — 둘 다 없으면 이 화면에서 + // 빠져나갈 방법이 없다 (#723). + WeeklyReportErrorContent( + state = state, + onWeekSelect = viewModel::selectWeek, + onRetry = viewModel::retry, + modifier = modifier, + ) } is WeeklyReportUiState.Success -> { @@ -56,6 +64,7 @@ fun WeeklyReportScreen( state = state, onWeekSelect = viewModel::selectWeek, onEmotionAnalysisRetry = viewModel::retryEmotionAnalysis, + onRetry = viewModel::retry, modifier = modifier, ) } @@ -67,6 +76,7 @@ private fun WeeklyReportContent( state: WeeklyReportUiState.Success, onWeekSelect: (java.time.LocalDate) -> Unit, onEmotionAnalysisRetry: () -> Unit, + onRetry: () -> Unit, modifier: Modifier = Modifier, ) { // Figma 노드 852:11543 — main 컨테이너의 섹션 간 gap=32, 시작 pt=8, 끝 pb=200. @@ -76,6 +86,15 @@ private fun WeeklyReportContent( contentPadding = PaddingValues(top = 8.dp, bottom = 200.dp), verticalArrangement = Arrangement.spacedBy(32.dp), ) { + state.loadFailure?.let { failure -> + item { + WeekLoadFailureBanner( + failure = failure, + onRetry = onRetry, + ) + } + } + item { WeeklyReportReviewCard( selectedMonday = state.selectedMonday, @@ -191,6 +210,82 @@ private fun ErrorBox( } } +/** + * 조회에 실패했지만 직전 리포트가 남아 있을 때의 배너 (#723). + * + * 어느 주차를 못 불렀는지 밝히고 재시도 수단을 준다. 화면 아래에는 직전에 성공한 + * 리포트가 그대로 남아 있어 다른 주차로 옮길 수도 있다. + */ +@Composable +private fun WeekLoadFailureBanner( + failure: WeeklyReportUiState.LoadFailure, + onRetry: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = + stringResource( + R.string.mindrecord_weekly_report_load_failed, + failure.failedWeekLabel.monthValue, + (failure.failedWeekLabel.dayOfMonth - 1) / 7 + 1, + ), + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + Text( + text = failure.message.asString(), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + TextButton(onClick = onRetry) { + Text( + text = stringResource(R.string.mindrecord_weekly_report_retry), + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + } + } +} + +/** + * 보여 줄 리포트가 하나도 없는 실패 화면 (#723). + * + * 오류 문구만 렌더하면 주차 선택 UI 까지 사라져 이 상태에서 빠져나갈 수 없다. + * 주차 드롭다운과 재시도를 함께 남긴다. + */ +@Composable +private fun WeeklyReportErrorContent( + state: WeeklyReportUiState.Error, + onWeekSelect: (java.time.LocalDate) -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + WeeklyReportReviewCard( + selectedMonday = state.failedMonday, + weekOptions = state.weekOptions, + onWeekSelect = onWeekSelect, + dateRange = "", + counts = emptyList(), + ) + Text( + text = state.message.asString(), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + TextButton(onClick = onRetry) { + Text( + text = stringResource(R.string.mindrecord_weekly_report_retry), + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + } + } +} + /** * 감정 카드 본문 문구. * @@ -240,6 +335,7 @@ private fun WeeklyReportScreenPreview() { ), onWeekSelect = {}, onEmotionAnalysisRetry = {}, + onRetry = {}, ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportUiState.kt index 0bebd964e6..aac9927cca 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportUiState.kt @@ -39,9 +39,30 @@ sealed interface WeeklyReportUiState { */ val summaryText: String, val dailyQuestions: List, + /** + * 주차 조회에 실패했지만 **직전에 보던 리포트가 남아 있는** 상태 (#723). + * + * 실패해도 화면 전체를 오류로 바꾸지 않는다. 리포트와 주차 선택 UI 를 유지한 채 + * 배너로만 알려야 사용자가 재시도하거나 다른 주차로 옮길 수 있다. + */ + val loadFailure: LoadFailure? = null, ) : WeeklyReportUiState + /** 화면을 유지한 채 알리는 조회 실패. [failedWeekLabel] 은 사용자가 고른 주의 월요일. */ + data class LoadFailure( + val message: UiText, + val failedWeekLabel: LocalDate, + ) + + /** + * 보여 줄 리포트가 하나도 없는 실패 (첫 조회부터 실패). + * + * 이때도 [weekOptions] 와 [failedMonday] 를 함께 넘긴다 — 재시도와 주차 재선택 수단을 + * 화면에 남겨야 복구가 가능하다. 종전에는 오류 문구 하나만 렌더해 둘 다 사라졌다 (#723). + */ data class Error( val message: UiText, + val weekOptions: List, + val failedMonday: LocalDate, ) : WeeklyReportUiState } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt index 41854664c0..13bce86c0c 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt @@ -65,6 +65,22 @@ class WeeklyReportViewModel fun selectWeek(monday: LocalDate) = load(monday) + /** + * 실패한 주차를 그대로 다시 조회한다 (#723). + * + * 실패 상태에서도 화면에 남은 재시도 수단이다. 실패 이력이 없으면 보고 있던 주를 + * 다시 부른다. + */ + fun retry() { + val target = + when (val phase = internalState.value.loadPhase) { + is LoadPhase.Failed -> phase.monday + is LoadPhase.Loaded -> phase.monday + LoadPhase.Loading -> return + } + load(target) + } + /** * 감정 분석 실패 상태에서 사용자가 누르는 재시도. * @@ -85,9 +101,14 @@ class WeeklyReportViewModel fun refreshOnReturn() { // 진입 직후의 ON_RESUME 은 init 로드와 겹친다 — 진행 중이면 건너뛴다. if (loadJob?.isActive == true) return + // 실패 상태면 **실패한 주**를 다시 시도한다. 이번 주로 되돌아가면 사용자가 + // 보려던 주차가 유실돼, 나갔다 들어와도 복구되지 않는다 (#723). val current = - (internalState.value.loadPhase as? LoadPhase.Loaded)?.monday - ?: weekOptions.first().monday + when (val phase = internalState.value.loadPhase) { + is LoadPhase.Loaded -> phase.monday + is LoadPhase.Failed -> phase.monday + LoadPhase.Loading -> weekOptions.first().monday + } load(current, showsLoading = false, keepsStateOnFailure = true) } @@ -99,6 +120,8 @@ class WeeklyReportViewModel loadJob?.cancel() loadJob = viewModelScope.launch { + // 실패했을 때 되돌아갈 화면을 **로딩으로 덮기 전에** 잡아 둔다 (#723). + val previousLoaded = internalState.value.loadPhase.lastLoadedOrNull() if (showsLoading) { internalState.update { it.copy(loadPhase = LoadPhase.Loading) } } @@ -132,10 +155,13 @@ class WeeklyReportViewModel current.copy( loadPhase = LoadPhase.Failed( - UiText.DynamicOrResource( - value = e.message, - fallbackResId = R.string.mindrecord_error_weekly_report_failed, - ), + message = + UiText.DynamicOrResource( + value = e.message, + fallbackResId = R.string.mindrecord_error_weekly_report_failed, + ), + monday = monday, + previous = previousLoaded, ), ) } @@ -241,16 +267,56 @@ class WeeklyReportViewModel val userName: String, ) : LoadPhase + /** + * 조회 실패. + * + * [monday] 는 **사용자가 고른 주**다. 이 값이 없으면 재진입 자동 갱신이 이번 주로 + * 되돌아가, 실패한 주를 다시 시도할 방법이 사라진다 (#723). + * + * [previous] 는 직전에 성공했던 화면이다. 남겨 두면 실패해도 리포트와 주차 선택 + * UI 를 유지할 수 있다 — 종전에는 화면 전체가 오류 문구 하나로 바뀌어, 재시도도 + * 다른 주차로 이동도 불가능했다. + */ data class Failed( val message: UiText, + val monday: LocalDate, + val previous: Loaded?, ) : LoadPhase } + /** 실패 상태에 보존해 둔 직전 성공 화면(없으면 null). */ + private fun LoadPhase.lastLoadedOrNull(): LoadPhase.Loaded? = + when (this) { + is LoadPhase.Loaded -> this + is LoadPhase.Failed -> previous + LoadPhase.Loading -> null + } + private fun InternalState.toUiState(): WeeklyReportUiState = when (val phase = loadPhase) { - LoadPhase.Loading -> WeeklyReportUiState.Loading - is LoadPhase.Failed -> WeeklyReportUiState.Error(phase.message) - is LoadPhase.Loaded -> phase.toSuccessUiState() + LoadPhase.Loading -> { + WeeklyReportUiState.Loading + } + + is LoadPhase.Loaded -> { + phase.toSuccessUiState() + } + + is LoadPhase.Failed -> { + // 직전에 보던 리포트가 있으면 그 화면을 유지하고 실패는 배너로 알린다. + // 화면 전체를 오류로 바꾸면 주차 선택 UI 까지 사라져 복구 수단이 없어진다. + phase.previous?.toSuccessUiState()?.copy( + loadFailure = + WeeklyReportUiState.LoadFailure( + message = phase.message, + failedWeekLabel = phase.monday, + ), + ) ?: WeeklyReportUiState.Error( + message = phase.message, + weekOptions = weekOptions, + failedMonday = phase.monday, + ) + } } private fun LoadPhase.Loaded.toSuccessUiState(): WeeklyReportUiState.Success { diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index 2d0e1c4154..1a6a3663b1 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -173,4 +173,7 @@ 답변을 입력해 주세요. 오늘의 질문을 불러오지 못해 저장할 수 없습니다. 잠시 후 다시 시도해 주세요. 주간 리포트를 불러오지 못했습니다. + + %1$d월 %2$d주차 리포트를 불러오지 못했어요. + 다시 시도 diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt new file mode 100644 index 0000000000..8a1ec8787a --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt @@ -0,0 +1,160 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.model.user.User +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.lang.reflect.Proxy +import java.time.DayOfWeek +import java.time.LocalDate + +/** + * 주차 조회 실패 뒤 복구 경로 가드 (#723). + * + * 종전에는 실패하면 화면 전체가 오류 문구 하나로 바뀌어 **리포트도 주차 선택 UI 도 + * 사라졌다.** 재진입 자동 갱신도 실패한 주차를 잊고 이번 주를 다시 불러, 나갔다 들어와도 + * 사용자가 보려던 주차로 돌아오지 못했다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WeeklyReportRecoveryTest { + private val dispatcher = StandardTestDispatcher() + private val thisMonday: LocalDate = LocalDate.now().with(DayOfWeek.MONDAY) + private val lastMonday: LocalDate = thisMonday.minusWeeks(1) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `주차 변경이 실패해도 직전 리포트와 주차 선택이 남는다`() = + runTest(dispatcher) { + val repository = ScriptedRepository(mapOf(thisMonday to Result.success(report()))) + val viewModel = start(repository) + + viewModel.selectWeek(lastMonday) // 이 주차는 스크립트에 없어 실패한다 + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue("오류 화면으로 갈아치우지 않는다", state is WeeklyReportUiState.Success) + state as WeeklyReportUiState.Success + assertNotNull("실패를 배너로 알린다", state.loadFailure) + assertEquals("못 불러온 주차를 밝힌다", lastMonday, state.loadFailure?.failedWeekLabel) + assertTrue("주차 선택 수단이 남는다", state.weekOptions.isNotEmpty()) + } + + @Test + fun `첫 조회부터 실패해도 주차 선택과 실패한 주차가 남는다`() = + runTest(dispatcher) { + val repository = ScriptedRepository(emptyMap()) + val viewModel = start(repository) + + val state = viewModel.uiState.value + assertTrue(state is WeeklyReportUiState.Error) + state as WeeklyReportUiState.Error + assertTrue("이 화면에서 빠져나갈 수단이 있어야 한다", state.weekOptions.isNotEmpty()) + assertEquals(thisMonday, state.failedMonday) + } + + @Test + fun `재진입 자동 갱신은 이번 주가 아니라 실패한 주차를 다시 부른다`() = + runTest(dispatcher) { + val repository = ScriptedRepository(mapOf(thisMonday to Result.success(report()))) + val viewModel = start(repository) + viewModel.selectWeek(lastMonday) + advanceUntilIdle() + + // 나갔다 돌아옴 — 그 사이 서버가 복구됐다고 본다. + repository.script = mapOf(lastMonday to Result.success(report(diaryAmount = 7))) + viewModel.refreshOnReturn() + advanceUntilIdle() + + val state = viewModel.uiState.value as WeeklyReportUiState.Success + assertEquals("사용자가 보려던 주차로 복구된다", lastMonday, state.selectedMonday) + assertNull("복구되면 배너는 사라진다", state.loadFailure) + } + + @Test + fun `재시도는 실패한 주차를 그대로 다시 부른다`() = + runTest(dispatcher) { + val repository = ScriptedRepository(mapOf(thisMonday to Result.success(report()))) + val viewModel = start(repository) + viewModel.selectWeek(lastMonday) + advanceUntilIdle() + + repository.script = mapOf(lastMonday to Result.success(report())) + viewModel.retry() + advanceUntilIdle() + + val state = viewModel.uiState.value as WeeklyReportUiState.Success + assertEquals(lastMonday, state.selectedMonday) + assertEquals(listOf(lastMonday, lastMonday), repository.requested.takeLast(2)) + } + + // ── 테스트 도구 ─────────────────────────────────────────────────────────── + + private class ScriptedRepository( + var script: Map>, + ) : WeeklyReportRepository { + val requested = mutableListOf() + + override suspend fun getWeeklyReport(date: String): Result { + val monday = LocalDate.parse(date) + requested += monday + return script[monday] ?: Result.failure(IllegalStateException("timeout")) + } + } + + private fun report(diaryAmount: Int = 0) = + WeeklyReport( + dailyQuestionAmount = 0, + diaryAmount = diaryAmount, + summaryText = "", + week = emptyList(), + dailyQuestions = emptyList(), + emotions = emptyList(), + emotionAnalysis = EmotionAnalysis(total = 0, succeeded = 0, pending = 0, failed = 0), + ) + + private fun TestScope.start(repository: WeeklyReportRepository): WeeklyReportViewModel { + val viewModel = WeeklyReportViewModel(repository, userRepository()) + backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } + advanceUntilIdle() + return viewModel + } + + private fun userRepository(): UserRepository = + Proxy.newProxyInstance( + UserRepository::class.java.classLoader, + arrayOf(UserRepository::class.java), + ) { _, method, _ -> + when (method.name) { + "getReceiverListFlow" -> flowOf(emptyList()) + "getMyProfile" -> User(name = "adamtia", email = "a@b.c", phone = null, profileImageUrl = null) + else -> error("Unexpected user repository call: ${method.name}") + } + } as UserRepository +}