diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordHomeRequestCountAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordHomeRequestCountAndroidTest.kt new file mode 100644 index 0000000000..c329232448 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordHomeRequestCountAndroidTest.kt @@ -0,0 +1,176 @@ +package com.afternote.afternote_fe + +import androidx.activity.compose.setContent +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.lifecycle.Lifecycle +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.testing.TestNavHostController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.afternote.afternote_fe.navigation.AppNavigation +import com.afternote.afternote_fe.navigation.AppState +import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.core.ui.Route +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import javax.inject.Inject + +/** + * 마음의 기록 첫 진입 요청 수를 **실제 화면 배선 위에서** 센다 (#736). + * + * `MindRecordRequestCountTest`(JVM) 는 ViewModel 을 직접 만들어 `refreshOnReturn()` 을 손으로 + * 부른다 — 그래서 [HomeScreen] 의 `HorizontalPager`, 탭 클릭, 화면별 `hiltViewModel()`, + * `LifecycleEventEffect` 를 하나도 지나지 않는다. 그 배선에서 VM 이 조기 생성되거나 0→2 이동 중 + * 일기 탭이 합성돼도 JVM 테스트는 계속 통과한다 (#736 리뷰). + * + * 그래서 여기서는 **실제 `AppNavigation` + Hilt 가 주입한 counting fake** 로 요청을 센다. + * 화면을 바꾸는 것이 아니라 «몇 번 불렀나» 를 보는 테스트라, 단언은 전부 저장소 호출 기록이다. + */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalTestApi::class) +class MindRecordHomeRequestCountAndroidTest { + @Inject + lateinit var dailyQuestionRepository: DailyQuestionRepository + + @Inject + lateinit var diaryRepository: DiaryRepository + + @Inject + lateinit var weeklyReportRepository: WeeklyReportRepository + + private val dailyQuestion get() = dailyQuestionRepository as FakeDailyQuestionRepository + private val diary get() = diaryRepository as FakeDiaryRepository + private val weekly get() = weeklyReportRepository as FakeWeeklyReportRepository + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @get:Rule(order = 2) + val failureArtifactRule = + FailureArtifactRule { + composeRule.onRoot().captureToImage().asAndroidBitmap() + } + + private lateinit var navController: TestNavHostController + + @Before + fun setUp() { + hiltRule.inject() + // 큐가 비면 fake 가 터진다 — 주간 탭을 여러 번 열어도 남도록 넉넉히 채운다. + repeat(WEEKLY_RESPONSE_SLOTS) { weekly.results.addLast(Result.success(emptyWeeklyReport())) } + composeRule.activityRule.scenario.onActivity { activity -> + navController = + TestNavHostController(activity).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + } + activity.setContent { + AfternoteTheme { + AppNavigation( + startDestination = Route.MindRecord, + appState = AppState(navController), + ) + } + } + } + } + + @Test + fun coldEntry_loadsOnlyTheVisibleTab() { + awaitDailyQuestionTab() + + // 보이는 탭(데일리 질문)만 부른다 — today 1, 목록 1. + assertEquals(1, dailyQuestion.getTodayCalls) + assertEquals(1, dailyQuestion.listQueries.size) + // 일기·주간리포트는 합성되지도 않았으므로 0 이어야 한다. 종전에는 세 VM 을 화면 위에서 + // 호이스팅해 열지도 않은 탭의 init 조회가 진입 즉시 나갔다 (#736). + assertEquals(0, diary.listQueries.size) + assertEquals(emptyList(), weekly.requestedDates) + } + + @Test + fun jumpToWeeklyTab_doesNotComposeTheDiaryTabInBetween() { + awaitDailyQuestionTab() + + clickTab(WEEKLY_REPORT_TAB) + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { weekly.requestedDates.isNotEmpty() } + composeRule.waitForIdle() + + // 0 → 2 는 `scrollToPage` 라 가운데 페이지를 거치지 않는다. `animateScrollToPage` 로 + // 되돌리면 일기 탭이 합성되며 `hiltViewModel()` 이 VM 을 만들어 init 조회가 나간다. + assertEquals(0, diary.listQueries.size) + assertEquals(1, weekly.requestedDates.size) + // 떠난 탭이 다시 부르지도 않는다. + assertEquals(1, dailyQuestion.getTodayCalls) + assertEquals(1, dailyQuestion.listQueries.size) + } + + @Test + fun resumeWithoutDataChange_addsNoRequests() { + awaitDailyQuestionTab() + val todayCalls = dailyQuestion.getTodayCalls + val listCalls = dailyQuestion.listQueries.size + + // 실제 ON_RESUME 을 발화시킨다 — 화면이 `LifecycleEventEffect(ON_RESUME)` 로 거는 + // 복귀 갱신이 여기서 돈다. 데이터가 그대로면 재조회 가드(#736)가 막아야 한다. + composeRule.activityRule.scenario.moveToState(Lifecycle.State.STARTED) + composeRule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + composeRule.waitForIdle() + + assertEquals(todayCalls, dailyQuestion.getTodayCalls) + assertEquals(listCalls, dailyQuestion.listQueries.size) + assertEquals(0, diary.listQueries.size) + assertEquals(emptyList(), weekly.requestedDates) + } + + /** 탭 텍스트는 하단 네비 라벨과 겹치지 않지만, 클릭 대상만 고르도록 좁힌다. */ + private fun clickTab(label: String) { + composeRule.onNode(hasText(label) and hasClickAction()).performClick() + } + + private fun awaitDailyQuestionTab() { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { dailyQuestion.getTodayCalls > 0 } + composeRule.waitForIdle() + } + + private fun emptyWeeklyReport(): WeeklyReport = + WeeklyReport( + dailyQuestionAmount = 0, + diaryAmount = 0, + summaryText = "", + week = emptyList(), + dailyQuestions = emptyList(), + emotions = emptyList(), + // 분석 상태는 이 테스트의 관심사가 아니다 — 완료로 고정해 폴링이 끼어들지 않게 한다. + emotionAnalysis = EmotionAnalysis(total = 0, succeeded = 0, pending = 0, failed = 0), + ) + + private companion object { + const val TIMEOUT_MILLIS = 10_000L + const val WEEKLY_REPORT_TAB = "주간리포트" + const val WEEKLY_RESPONSE_SLOTS = 8 + } +} diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt index 89577a602f..9850a112fa 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -50,6 +50,7 @@ import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion import com.afternote.feature.mindrecord.domain.model.WeeklyReportDay import com.afternote.feature.mindrecord.domain.model.WeeklyReportEmotion +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository @@ -363,11 +364,11 @@ class TimeLetterMindRecordCompletionAndroidTest { pending?.await() ?: Result.success(emptyList()) } } - var activeViewModel by mutableStateOf(DailyQuestionListViewModel(emptyRepository)) + var activeViewModel by mutableStateOf(DailyQuestionListViewModel(emptyRepository, MindRecordChangeTracker())) composeRule.setContent { AfternoteTheme { - DailyQuestionAnswerListScreen(viewModel = activeViewModel) + DailyQuestionAnswerListScreen(viewModel = activeViewModel, onItemClick = { _, _ -> }) } } @@ -391,7 +392,7 @@ class TimeLetterMindRecordCompletionAndroidTest { FakeDailyQuestionRepository(today = completionToday()).apply { onGetList = { _, _ -> listResults.removeFirst() } } - val retryViewModel = DailyQuestionListViewModel(retryRepository) + val retryViewModel = DailyQuestionListViewModel(retryRepository, MindRecordChangeTracker()) composeRule.runOnIdle { activeViewModel = retryViewModel } composeRule.waitUntil(timeoutMillis = TIMEOUT) { retryViewModel.uiState.value is DailyQuestionListUiState.Error @@ -418,8 +419,12 @@ class TimeLetterMindRecordCompletionAndroidTest { @Test fun dailyQuestionWrite_successRefreshesExistingListWithCreatedAnswer() { - val repository = FakeDailyQuestionRepository(today = completionToday()) - val listViewModel = DailyQuestionListViewModel(repository) + // 프로덕션에서는 data 계층이 쓰기 성공에 notifyChanged() 를 부르고, 목록이 그 버전을 + // 보고 재조회한다 (#736). fake 가 그 배선을 흉내 내지 않으면 «작성하고 돌아왔는데 + // 목록이 그대로»(#520) 를 잡는 이 테스트가 조용히 침묵한다 (#966 리뷰). + val changeTracker = MindRecordChangeTracker() + val repository = FakeDailyQuestionRepository(today = completionToday(), changeTracker = changeTracker) + val listViewModel = DailyQuestionListViewModel(repository, changeTracker) var writeViewModel by mutableStateOf(null) var submitSuccessCalls = 0 @@ -427,7 +432,7 @@ class TimeLetterMindRecordCompletionAndroidTest { val activeWriteViewModel = writeViewModel AfternoteTheme { if (activeWriteViewModel == null) { - DailyQuestionAnswerListScreen(viewModel = listViewModel) + DailyQuestionAnswerListScreen(viewModel = listViewModel, onItemClick = { _, _ -> }) } else { DailyQuestionWriteScreen( viewModel = activeWriteViewModel, @@ -663,7 +668,7 @@ class TimeLetterMindRecordCompletionAndroidTest { profile = User("주간 사용자", "weekly@afternote.local", null, null), receivers = emptyList(), ) - val viewModel = WeeklyReportViewModel(repository, userRepository) + val viewModel = WeeklyReportViewModel(repository, userRepository, MindRecordChangeTracker()) composeRule.setContent { AfternoteTheme { diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt index 859b43e9f2..7b12dfa679 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt @@ -9,13 +9,13 @@ import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.domain.testing.FakeUserProfileRepository import com.afternote.feature.mindrecord.data.di.MindRecordRepositoryModule import com.afternote.feature.mindrecord.data.repositoryimpl.MindRecordReceiverRepositoryImpl -import com.afternote.feature.mindrecord.data.repositoryimpl.WeeklyReportRepositoryImpl import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent @@ -59,9 +59,14 @@ object TestMindRecordRepositoryModule { @Singleton fun provideMindRecordReceiverRepository(impl: MindRecordReceiverRepositoryImpl): MindRecordReceiverRepository = impl + /** + * 실제 구현은 네트워크를 탄다 — 주간리포트 탭이 합성되기만 해도 계측이 실서버에 붙는다. + * 요청한 주차를 기록하는 fake 로 바꿔, 「열지 않은 탭은 부르지 않는다」(#736) 를 **횟수로** + * 단언할 수 있게 한다. + */ @Provides @Singleton - fun provideWeeklyReportRepository(impl: WeeklyReportRepositoryImpl): WeeklyReportRepository = impl + fun provideWeeklyReportRepository(): WeeklyReportRepository = FakeWeeklyReportRepository() } @Module diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt index 9d66666006..815146a715 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt @@ -131,6 +131,20 @@ fun rememberMindRecordNavActions(navController: NavController): MindRecordNavAct navController.popBackStack() } + override fun onOpenRecordDetail( + recordId: Long, + isDiary: Boolean, + yearMonth: String?, + ) { + navController.navigate( + MindRecordRoute.RecordDetailRoute( + recordId = recordId, + isDiary = isDiary, + yearMonth = yearMonth, + ), + ) + } + override fun onEditDailyQuestion(answerId: Long) { navController.navigate(MindRecordRoute.DailyQuestionWriteRoute(answerId = answerId)) } @@ -404,10 +418,6 @@ fun rememberHomeTabActions( appState.navigateToBottomBarRoute(Route.MindRecord) } - override fun onWeeklyRecentRecordClick() { - appState.navigateToBottomBarRoute(Route.MindRecord) - } - override fun onMemoriesSectionClick() { appState.navController.navigate(Route.MemorySpace) } diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt index bcfdf9a1d1..30e45fd842 100644 --- a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt @@ -89,8 +89,6 @@ interface HomeTabActions { fun onWeeklyCountClick() - fun onWeeklyRecentRecordClick() - fun onMemoriesSectionClick() fun onSettingClick() @@ -111,8 +109,6 @@ private object HomeTabActionsNoop : HomeTabActions { override fun onWeeklyCountClick() {} - override fun onWeeklyRecentRecordClick() {} - override fun onMemoriesSectionClick() {} override fun onSettingClick() {} @@ -291,9 +287,11 @@ private fun HomeTabScrollContent( item { WeeklySummaryGrid( + // 값을 붙이는 것은 후속(#562)이다. 그때까지 0 을 그려 «이번 주에 아무것도 + // 안 썼다» 로 읽히게 두지 않는다 — 미상은 미상으로 표시한다. + recordedCount = null, onImageClick = actions::onWeeklyImageClick, onCountCardClick = actions::onWeeklyCountClick, - onRecentRecordClick = actions::onWeeklyRecentRecordClick, ) Spacer(modifier = Modifier.height(40.dp)) } diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGrid.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGrid.kt index 8bb11cef7f..871cac8672 100644 --- a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGrid.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGrid.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Surface @@ -22,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -30,16 +32,23 @@ import androidx.compose.ui.unit.sp import com.afternote.core.ui.R import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.home.presentation.R as HomeR @Composable fun WeeklySummaryGrid( modifier: Modifier = Modifier, - recordedCount: Int = 7, - recentRecordDate: String = "26.02.01", - recentRecordTitle: String = "최근 기록 제목최근 기록 제목...", + /** + * 이번 주 기록 수. **기본값을 두지 않는다** — 넘기지 않은 호출부가 컴파일에서 걸려야 + * 이 자리가 잊히지 않는다 (#207 리뷰). + * + * 종전 기본값 `7` 은 명백한 목업이라 오히려 눈에 띄었는데, `0` 은 «이번 주에 아무것도 + * 안 썼다» 는 **그럴듯한 거짓**이라 사용자가 그대로 믿는다. + * + * `null` 은 «아직 모름» 이라 숫자 대신 대시를 그린다. 0 은 확정값이라 그대로 그린다. + */ + recordedCount: Int?, onImageClick: () -> Unit = {}, onCountCardClick: () -> Unit = {}, - onRecentRecordClick: () -> Unit = {}, ) { BoxWithConstraints(modifier = modifier.fillMaxWidth()) { val gap = 8.dp @@ -76,14 +85,17 @@ fun WeeklySummaryGrid( } } - // [우측] 작은 정사각형 카드 2개를 담은 컬럼 + // [우측] 이번 주 기록 횟수 카드. + // + // 종전에는 이 자리에 작은 카드 2장(기록 횟수 / 최근 깊은생각)이 있었다. 깊은 + // 생각이 기획에서 제거되면서 아래 카드가 사라져, 남은 한 장이 왼쪽 큰 카드와 + // 같은 높이를 채운다 — 한 장만 남기고 아래를 비우면 그리드가 어긋난다. Column( - modifier = Modifier.height(largeSquareSize), // 왼쪽 큰 카드와 높이를 동일하게 맞춤 + modifier = Modifier.height(largeSquareSize), verticalArrangement = Arrangement.spacedBy(gap), ) { - // [우측 상단] 이번 주 기록 횟수 카드 Surface( - modifier = Modifier.size(smallSquareSize), + modifier = Modifier.width(smallSquareSize).height(largeSquareSize), shape = RoundedCornerShape(6.dp), border = BorderStroke(1.dp, AfternoteDesign.colors.gray2), color = AfternoteDesign.colors.white, @@ -109,7 +121,9 @@ fun WeeklySummaryGrid( ) Spacer(modifier = Modifier.weight(1f)) Text( - text = recordedCount.toString(), + text = + recordedCount?.toString() + ?: stringResource(HomeR.string.home_tab_weekly_count_unavailable), style = AfternoteDesign.typography.inter.copy( fontSize = 24.sp, @@ -121,43 +135,6 @@ fun WeeklySummaryGrid( ) } } - - // [우측 하단] 최근 기록 카드 - Surface( - modifier = Modifier.size(smallSquareSize), - shape = RoundedCornerShape(6.dp), - color = AfternoteDesign.colors.gray8, - onClick = onRecentRecordClick, - ) { - Column( - modifier = - Modifier - .padding(top = 17.dp, bottom = 12.dp) - .padding(horizontal = 17.dp), - ) { - Icon( - painter = painterResource(id = R.drawable.core_ui_ic_deep_thought_moon), // 프로젝트 리소스 - contentDescription = null, - tint = AfternoteDesign.colors.white, - modifier = Modifier.size(13.dp), - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = recentRecordDate, - style = AfternoteDesign.typography.mono, - color = AfternoteDesign.colors.white, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = recentRecordTitle, - style = - AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.white, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } } } } @@ -167,6 +144,6 @@ fun WeeklySummaryGrid( @Composable private fun WeeklySummaryGridPreview() { AfternoteTheme { - WeeklySummaryGrid() + WeeklySummaryGrid(recordedCount = 3) } } diff --git a/feature/home/presentation/src/main/res/values/strings.xml b/feature/home/presentation/src/main/res/values/strings.xml index 2ac0693b4a..e8b62340c0 100644 --- a/feature/home/presentation/src/main/res/values/strings.xml +++ b/feature/home/presentation/src/main/res/values/strings.xml @@ -6,4 +6,6 @@ 애프터노트 입력하러가기 정보를 불러오지 못했습니다. 다시 시도 + + – diff --git a/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTest.kt b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTest.kt index 5b4461d3dd..80c5985b97 100644 --- a/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTest.kt +++ b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTest.kt @@ -13,6 +13,6 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun weeklySummaryGridScreenshot() { AfternoteTheme { - WeeklySummaryGrid(modifier = Modifier.padding(16.dp)) + WeeklySummaryGrid(recordedCount = 3, modifier = Modifier.padding(16.dp)) } } diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png index cd5133a0ce..a694eab947 100644 Binary files a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png differ diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenSuccessScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenSuccessScreenshot_748aa731_0.png index 6ee231ee03..20e28bf4fc 100644 Binary files a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenSuccessScreenshot_748aa731_0.png and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenSuccessScreenshot_748aa731_0.png differ diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTestKt/weeklySummaryGridScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTestKt/weeklySummaryGridScreenshot_748aa731_0.png index bbb9cfe670..27a47814d6 100644 Binary files a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTestKt/weeklySummaryGridScreenshot_748aa731_0.png and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTestKt/weeklySummaryGridScreenshot_748aa731_0.png differ diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/di/MindRecordServiceModule.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/di/MindRecordServiceModule.kt index 99c9772199..362a8395e6 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/di/MindRecordServiceModule.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/di/MindRecordServiceModule.kt @@ -4,6 +4,7 @@ import com.afternote.feature.mindrecord.data.api.DailyQuestionApiService import com.afternote.feature.mindrecord.data.api.DiaryApiService import com.afternote.feature.mindrecord.data.api.MindRecordReceiverApiService import com.afternote.feature.mindrecord.data.api.WeeklyReportApiService +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -15,6 +16,11 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object MindRecordServiceModule { + /** 자동 갱신을 데이터 변경 기준으로 좁히기 위한 전역 버전 (#736). */ + @Provides + @Singleton + fun provideMindRecordChangeTracker(): MindRecordChangeTracker = MindRecordChangeTracker() + @Provides @Singleton fun provideDailyQuestionApiService(retrofit: Retrofit): DailyQuestionApiService = retrofit.create() diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt index d6ff9d4967..999c6bbd8b 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt @@ -48,9 +48,12 @@ data class DailyQuestionListItemDto( // // 기본값 `false` 는 두지 않는다 — 서버가 항상 보내는 키라, 빠졌을 때 "임시저장 아님" 으로 // 접히면 임시저장 글이 목록에 그대로 노출된다. 계약 누락은 파싱 실패로 드러나야 한다 (#789). + // 기본값을 두지 않는다 — 키가 빠지면 false 로 접혀 임시저장이 목록에 샌다 (#789). @SerialName("isDraft") @JsonNames("draft") val isDraft: Boolean, + // 상세 화면의 "수신인 OOO" 표시용 (#759). + @SerialName("receivers") val receivers: List, ) @Serializable diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt index f039e4dc91..c58609710e 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt @@ -78,9 +78,20 @@ data class DiaryListItemDto( // 저장 컬럼이 필수라 응답도 항상 채워진다. AI 가 매기는 `emotion` 과 달리 사용자가 직접 // 고른 값이고, 한글 값이 관측된 쪽도 `emotion` 이지 이 필드가 아니다 (#591, #789). @SerialName("todayMood") val todayMood: TodayMoodDto, + // 기본값을 두지 않는다 — 키가 빠지면 false 로 접혀 임시저장이 목록에 샌다 (#789). @SerialName("isDraft") @JsonNames("draft") val isDraft: Boolean, + // 상세 화면이 "수신인 OOO" 로 보여준다. 서버가 늘 함께 내려주는데 종전에는 선언하지 + // 않아 버려졌다 (#759). + @SerialName("receivers") val receivers: List, +) + +/** 기록에 지정된 수신자 요약 (OpenAPI `MindRecordReceiverSummaryResponse`). */ +@Serializable +data class MindRecordReceiverDto( + @SerialName("receiverId") val receiverId: Long, + @SerialName("name") val name: String, ) // `/diary` 응답의 `data` 는 객체 — `diaries` 외에 조회 대상 달의 비-임시 다이어리 수 diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DailyQuestionMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DailyQuestionMapper.kt index 2b18f82112..299a928227 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DailyQuestionMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DailyQuestionMapper.kt @@ -16,6 +16,7 @@ fun DailyQuestionListItemDto.toDomain(): DailyQuestion = content = content, createdAt = createdAt, isDraft = isDraft, + receiverNames = receivers.map { it.name }, ) fun TodayDailyQuestionDto.toDomain(): TodayDailyQuestion = diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt index 44b0db53ff..a5aaad9aa3 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt @@ -21,6 +21,7 @@ fun DiaryListItemDto.toDomain(): Diary = todayMood = todayMood.toDomain(), imageUrl = imageUrl, isDraft = isDraft, + receiverNames = receivers.map { it.name }, ) fun DiaryListDto.toDomain(): DiaryList = diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DailyQuestionRepositoryImpl.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DailyQuestionRepositoryImpl.kt index b0bee02a84..a1ba966ec7 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DailyQuestionRepositoryImpl.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DailyQuestionRepositoryImpl.kt @@ -11,12 +11,14 @@ import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import javax.inject.Inject class DailyQuestionRepositoryImpl @Inject constructor( private val api: DailyQuestionApiService, + private val changeTracker: MindRecordChangeTracker, ) : DailyQuestionRepository { override suspend fun getList( date: String?, @@ -40,7 +42,7 @@ class DailyQuestionRepositoryImpl .createDailyQuestion(payload.toRequest()) .requireData() .userDailyQuestionId - } + }.onSuccess { changeTracker.notifyChanged() } override suspend fun update( id: Long, @@ -51,10 +53,10 @@ class DailyQuestionRepositoryImpl .updateDailyQuestion(userDailyQuestionId = id, request = payload.toRequest()) .requireData() .userDailyQuestionId - } + }.onSuccess { changeTracker.notifyChanged() } override suspend fun delete(id: Long): Result = runCatchingCancellable { api.deleteDailyQuestion(userDailyQuestionId = id).requireStatus() - } + }.onSuccess { changeTracker.notifyChanged() } } diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DiaryRepositoryImpl.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DiaryRepositoryImpl.kt index e5f0eae266..6718fab826 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DiaryRepositoryImpl.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DiaryRepositoryImpl.kt @@ -10,12 +10,14 @@ import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import javax.inject.Inject class DiaryRepositoryImpl @Inject constructor( private val api: DiaryApiService, + private val changeTracker: MindRecordChangeTracker, ) : DiaryRepository { override suspend fun getList( yearMonth: String, @@ -31,7 +33,7 @@ class DiaryRepositoryImpl override suspend fun create(payload: DiaryCreatePayload): Result = runCatchingCancellable { api.createDiary(payload.toRequest()).requireStatus() - } + }.onSuccess { changeTracker.notifyChanged() } override suspend fun update( id: Long, @@ -39,10 +41,10 @@ class DiaryRepositoryImpl ): Result = runCatchingCancellable { api.updateDiary(diaryId = id, request = payload.toRequest()).requireStatus() - } + }.onSuccess { changeTracker.notifyChanged() } override suspend fun delete(id: Long): Result = runCatchingCancellable { api.deleteDiary(diaryId = id).requireStatus() - } + }.onSuccess { changeTracker.notifyChanged() } } diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionImageContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionImageContractTest.kt index 888a086794..9abfc82e1b 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionImageContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionImageContractTest.kt @@ -55,6 +55,7 @@ class DailyQuestionImageContractTest { { "status": 200, "code": 200, "data": [{ "userDailyQuestionId": 26, "title": "질문", "isDraft": false, "createdAt": "2026.08.23 일", + "receivers": [], "content": "

사진과 함께

\"사진\"

" }] } """.trimIndent() @@ -79,7 +80,7 @@ class DailyQuestionImageContractTest { """ { "status": 200, "code": 200, "data": [{ "userDailyQuestionId": 1, "title": "질문", "content": "

본문

", - "createdAt": "2026.08.23 일", "isDraft": false }] } + "createdAt": "2026.08.23 일", "isDraft": false, "receivers": [] }] } """.trimIndent() val item = diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt index ea8a79ec23..fa352cf7da 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt @@ -77,7 +77,8 @@ class DiaryListContractTest { { "status": 200, "code": 200, "data": { "diaries": [{ "id": 123, "date": "2026-03-21", "createdAt": "2026.03.21 토", - "title": "t", "content": "c", "todayMood": "SOSO", "isDraft": true }], + "title": "t", "content": "c", "todayMood": "SOSO", "isDraft": true, + "receivers": [] }], "monthDiaryCount": 1, "weeklyDominantMood": null } } """.trimIndent() diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt index de8cde2b99..91033a1847 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt @@ -51,7 +51,7 @@ class MindRecordDtoContractTest { """ { "status": 200, "code": 200, "data": [{ "userDailyQuestionId": 1, "title": "t", "content": "c", - "createdAt": "2026.08.23 일", "isDraft": false }] } + "createdAt": "2026.08.23 일", "isDraft": false, "receivers": [] }] } """.trimIndent() val decoded = json.decodeFromString(BaseResponse.serializer(ListSerializerOf), body) diff --git a/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordCancellationTest.kt b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordCancellationTest.kt index bae7e3fe8e..ff08193d98 100644 --- a/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordCancellationTest.kt +++ b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordCancellationTest.kt @@ -11,6 +11,7 @@ import com.afternote.feature.mindrecord.data.dto.DiaryCreateRequestDto import com.afternote.feature.mindrecord.data.dto.DiaryListDto import com.afternote.feature.mindrecord.data.dto.DiaryUpdateRequestDto import com.afternote.feature.mindrecord.data.dto.TodayDailyQuestionDto +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin @@ -34,7 +35,7 @@ class MindRecordCancellationTest { @Test fun `일기 조회 취소는 Result 로 돌아오지 않는다`() = runTest { - val repository = DiaryRepositoryImpl(NeverReturningDiaryApi()) + val repository = DiaryRepositoryImpl(NeverReturningDiaryApi(), MindRecordChangeTracker()) var returned: Result<*>? = null val job = @@ -50,7 +51,7 @@ class MindRecordCancellationTest { @Test fun `데일리질문 조회 취소는 Result 로 돌아오지 않는다`() = runTest { - val repository = DailyQuestionRepositoryImpl(NeverReturningDailyQuestionApi()) + val repository = DailyQuestionRepositoryImpl(NeverReturningDailyQuestionApi(), MindRecordChangeTracker()) var returned: Result<*>? = null val job = diff --git a/feature/mindrecord/domain/build.gradle.kts b/feature/mindrecord/domain/build.gradle.kts index bc211e4790..6a47404618 100644 --- a/feature/mindrecord/domain/build.gradle.kts +++ b/feature/mindrecord/domain/build.gradle.kts @@ -6,5 +6,6 @@ plugins { dependencies { implementation(libs.coroutines.core) + testImplementation(libs.coroutines.test) testFixturesImplementation(libs.coroutines.core) } diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DailyQuestionModels.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DailyQuestionModels.kt index 0cf54fa9f0..b0557511dd 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DailyQuestionModels.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DailyQuestionModels.kt @@ -6,6 +6,8 @@ data class DailyQuestion( val content: String, val createdAt: String, val isDraft: Boolean = false, + /** 이 기록을 전달받을 수신자 이름들 (#759). */ + val receiverNames: List = emptyList(), ) data class TodayDailyQuestion( diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt index 89344d494d..dea157622d 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt @@ -21,6 +21,8 @@ data class Diary( */ val imageUrl: String? = null, val isDraft: Boolean = false, + /** 이 기록을 전달받을 수신자 이름들. 상세 화면이 "수신인 OOO" 로 보여준다 (#759). */ + val receiverNames: List = emptyList(), ) data class DiaryList( diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt index 9b09a99c5b..2f91ef726f 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt @@ -11,7 +11,16 @@ data class WeeklyReport( val emotions: List, /** 서버가 주지 않으면 null — 상태는 [EmotionAnalysisStatus.UNKNOWN] 이 된다 (#725). */ val emotionAnalysis: EmotionAnalysis?, -) +) { + /** + * 이번 주 기록 수 — 홈 `WeeklySummaryGrid` 의 THIS WEEK 카드 값 (#207). + * + * 깊은 생각은 기획에서 제거된 기능이라 세지 않는다. 서버는 `deepThoughtAmount` 를 + * 계속 내려주지만 DTO 에 선언하지 않아 그대로 무시된다. + */ + val totalRecordAmount: Int + get() = dailyQuestionAmount + diaryAmount +} /** * 그 주 감정 분석 진행 상태. diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/sync/MindRecordChangeTracker.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/sync/MindRecordChangeTracker.kt new file mode 100644 index 0000000000..66496c7966 --- /dev/null +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/sync/MindRecordChangeTracker.kt @@ -0,0 +1,31 @@ +package com.afternote.feature.mindrecord.domain.sync + +import java.util.concurrent.atomic.AtomicLong + +/** + * 마음의 기록 데이터가 바뀐 횟수. **자동 갱신을 데이터 변경 기준으로 좁히기 위한 값**이다 (#736). + * + * 종전에는 탭 전환과 `ON_RESUME` 이 무조건 재조회를 걸었다. 화면 off/on, 홈 버튼 복귀, + * 권한 다이얼로그 닫기까지 같은 조회를 다시 내보내, 마음의 기록 첫 진입 한 번에 요청이 + * 7건 나갔다(화면에 필요한 건 2건). + * + * 갱신 자체를 없애면 #520 이 되돌아온다 — 작성하고 돌아왔는데 목록이 그대로인 문제다. + * 그래서 "돌아왔으니 다시 부른다" 를 **"바뀌었으니 다시 부른다"** 로 바꾼다. 쓰기가 + * 성공하면 [notifyChanged] 로 버전을 올리고, 화면은 자기가 읽어 둔 버전과 다를 때만 + * 다시 부른다. + * + * 프로세스 전역 단일 인스턴스이며 스레드 안전하다. 어느 화면에서 쓴 변경이든 다른 화면의 + * 갱신 판단에 그대로 반영돼야 하므로 저장소는 공유한다. + */ +class MindRecordChangeTracker { + private val counter = AtomicLong(0L) + + /** 현재 데이터 버전. 값 자체에 의미는 없고 **달라졌는지**만 본다. */ + val version: Long + get() = counter.get() + + /** 일기·데일리질문의 생성·수정·삭제가 성공했을 때 호출한다. */ + fun notifyChanged() { + counter.incrementAndGet() + } +} diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/usecase/GetWeeklyRecordCountUseCase.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/usecase/GetWeeklyRecordCountUseCase.kt new file mode 100644 index 0000000000..6820113cf8 --- /dev/null +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/usecase/GetWeeklyRecordCountUseCase.kt @@ -0,0 +1,31 @@ +package com.afternote.feature.mindrecord.domain.usecase + +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +/** + * 이번 주 기록 수 — 홈 `WeeklySummaryGrid` 의 THIS WEEK 카드 (#207). + * + * 일기·데일리질문·깊은 생각을 **각각 세지 않는다.** 주간리포트 API 가 두 카운트를 한 + * 응답에 담아 주므로 그 합을 쓴다 — 종류별로 목록을 부르면 요청이 셋으로 늘고, 화면이 + * 쓰지도 않을 본문까지 받아 온다. + * + * 조회 기준일은 **이번 주 월요일**이다. 서버가 그 날이 속한 주를 돌려준다. + */ +class GetWeeklyRecordCountUseCase + @Inject + constructor( + private val weeklyReportRepository: WeeklyReportRepository, + ) { + suspend operator fun invoke(today: LocalDate = LocalDate.now()): Result = + weeklyReportRepository + .getWeeklyReport(date = today.with(DayOfWeek.MONDAY).format(API_DATE_FORMATTER)) + .map { it.totalRecordAmount } + + private companion object { + val API_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + } + } diff --git a/feature/mindrecord/domain/src/test/kotlin/com/afternote/feature/mindrecord/domain/usecase/HomeSummaryUseCaseTest.kt b/feature/mindrecord/domain/src/test/kotlin/com/afternote/feature/mindrecord/domain/usecase/HomeSummaryUseCaseTest.kt new file mode 100644 index 0000000000..216359d1be --- /dev/null +++ b/feature/mindrecord/domain/src/test/kotlin/com/afternote/feature/mindrecord/domain/usecase/HomeSummaryUseCaseTest.kt @@ -0,0 +1,76 @@ +package com.afternote.feature.mindrecord.domain.usecase + +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.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * 발신자 홈 `WeeklySummaryGrid` 데이터 소스 (#207). + * + * 세 카드가 모두 목데이터였고 mindrecord 도메인에 UseCase 가 0건이었다. 여기서 고정하는 + * 계약은 두 가지다 — **주간 기록 수는 세 종류의 합**, **최근 깊은 생각은 1건 또는 없음**. + */ +class HomeSummaryUseCaseTest { + @Test + fun `이번 주 기록 수는 일기와 데일리질문의 합이다`() = + runTest { + val repository = FakeWeeklyReportRepository(report(daily = 2, diary = 4)) + + val count = GetWeeklyRecordCountUseCase(repository)(today = LocalDate.of(2026, 8, 24)) + + assertEquals(6, count.getOrNull()) + } + + @Test + fun `조회 기준일은 그 주 월요일이다`() = + runTest { + // 서버는 주어진 날이 속한 주를 돌려준다 — 일요일에 열어도 같은 주를 봐야 한다. + val repository = FakeWeeklyReportRepository(report()) + + GetWeeklyRecordCountUseCase(repository)(today = LocalDate.of(2026, 8, 30)) + + assertEquals("2026-08-24", repository.requestedDate) + } + + @Test + fun `주간 조회 실패는 0 으로 덮지 않는다`() = + runTest { + // 0 으로 내리면 "이번 주에 아무것도 안 썼다" 와 구분되지 않는다. + val repository = FakeWeeklyReportRepository(null) + + val count = GetWeeklyRecordCountUseCase(repository)() + + assertTrue(count.isFailure) + } + + // ── 테스트 도구 ─────────────────────────────────────────────────────────── + + private fun report( + daily: Int = 0, + diary: Int = 0, + ) = WeeklyReport( + dailyQuestionAmount = daily, + diaryAmount = diary, + summaryText = "", + week = emptyList(), + dailyQuestions = emptyList(), + emotions = emptyList(), + emotionAnalysis = EmotionAnalysis(total = 0, succeeded = 0, pending = 0, failed = 0), + ) + + private class FakeWeeklyReportRepository( + private val report: WeeklyReport?, + ) : WeeklyReportRepository { + var requestedDate: String? = null + + override suspend fun getWeeklyReport(date: String): Result { + requestedDate = date + return report?.let { Result.success(it) } ?: Result.failure(IllegalStateException("timeout")) + } + } +} diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDailyQuestionRepository.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDailyQuestionRepository.kt index 0d27b391e0..9b9334279e 100644 --- a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDailyQuestionRepository.kt +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDailyQuestionRepository.kt @@ -5,6 +5,7 @@ import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker /** * [DailyQuestionRepository] fake 정본. @@ -27,6 +28,15 @@ class FakeDailyQuestionRepository( var onCreate: (suspend (DailyQuestionCreatePayload) -> Result)? = null, var onUpdate: (suspend (Long, DailyQuestionUpdatePayload) -> Result)? = null, var onDelete: (suspend (Long) -> Result)? = null, + /** + * 쓰기 성공을 알릴 변경 추적기. **프로덕션에서는 data 계층(`DailyQuestionRepositoryImpl`)이 + * 이 자리에서 `notifyChanged()` 를 부른다** — fake 가 그걸 흉내 내지 않으면 목록 화면의 + * 재조회 가드(#736)가 «데이터가 안 바뀌었다» 로 보고 갱신을 건너뛴다. 그러면 «작성하고 + * 돌아왔는데 목록이 그대로»(#520) 를 잡는 테스트가 조용히 침묵한다 (#966 리뷰). + * + * 재조회를 보지 않는 시나리오는 넘기지 않아도 된다. + */ + private val changeTracker: MindRecordChangeTracker? = null, ) : DailyQuestionRepository { /** 메모리 저장소. 테스트가 직접 들여다보거나 조작해도 된다. */ val answers: MutableList = initialAnswers.toMutableList() @@ -82,6 +92,7 @@ class FakeDailyQuestionRepository( isDraft = payload.isDraft, ) today = today.copy(isAnswered = !payload.isDraft, isDraft = payload.isDraft) + changeTracker?.notifyChanged() return Result.success(id) } @@ -101,6 +112,7 @@ class FakeDailyQuestionRepository( answer } } + changeTracker?.notifyChanged() return Result.success(id) } @@ -108,6 +120,7 @@ class FakeDailyQuestionRepository( deletedIds += id onDelete?.let { return it(id) } answers.removeAll { it.dailyQuestionId == id } + changeTracker?.notifyChanged() return Result.success(Unit) } diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt index 5f232eb859..6814dd9c85 100644 --- a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt @@ -5,6 +5,7 @@ import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker /** * [DiaryRepository] fake 정본. 규약은 [FakeDailyQuestionRepository] 와 같다 — @@ -19,6 +20,11 @@ class FakeDiaryRepository( var onCreate: (suspend (DiaryCreatePayload) -> Result)? = null, var onUpdate: (suspend (Long, DiaryUpdatePayload) -> Result)? = null, var onDelete: (suspend (Long) -> Result)? = null, + /** + * 쓰기 성공을 알릴 변경 추적기 — 프로덕션에서는 data 계층이 이 자리에서 부른다. + * fake 가 흉내 내지 않으면 목록 재조회 가드(#736)가 갱신을 건너뛴다 (#966 리뷰). + */ + private val changeTracker: MindRecordChangeTracker? = null, ) : DiaryRepository { val diaries: MutableList = initialDiaries.toMutableList() @@ -51,6 +57,7 @@ class FakeDiaryRepository( override suspend fun create(payload: DiaryCreatePayload): Result { createdPayloads += payload onCreate?.let { return it(payload) } + changeTracker?.notifyChanged() return Result.success(Unit) } @@ -73,6 +80,7 @@ class FakeDiaryRepository( diary } } + changeTracker?.notifyChanged() return Result.success(Unit) } @@ -80,6 +88,7 @@ class FakeDiaryRepository( deletedIds += id onDelete?.let { return it(id) } diaries.removeAll { it.diaryId == id } + changeTracker?.notifyChanged() return Result.success(Unit) } diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 4dce575c34..5efcde5146 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { testImplementation(testFixtures(projects.core.domain)) testImplementation(testFixtures(projects.feature.mindrecord.domain)) + // 캘린더 날짜 셀 상호작용을 JVM 에서 실제로 눌러 확인한다 (#724). // 첨부 파일명 해석은 ContentResolver 를 타므로 Robolectric 으로 검증한다 (#731). testImplementation(libs.androidx.test.core.ktx) // 컴파일된 리소스로 문구를 검증한다 — aapt2 의 앞뒤 공백 제거는 소스 XML 만 봐서는 잡히지 않는다 (#732). diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyCalendar.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyCalendar.kt index 5db76bbfa2..b072dafd7f 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyCalendar.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyCalendar.kt @@ -40,6 +40,10 @@ fun DailyCalendar( modifier: Modifier = Modifier, answeredDays: Set = emptySet(), emotionByDay: Map = emptyMap(), + /** 사용자가 고른 날(같은 달의 일자). 없으면 선택 없음 (#724). */ + selectedDay: Int? = null, + /** 날짜 셀 탭. null 이면 셀이 클릭 대상이 아니다. */ + onDayClick: ((Int) -> Unit)? = null, ) { val days = buildDays(year, month, answeredDays, emotionByDay) @@ -118,7 +122,15 @@ fun DailyCalendar( Row(modifier = Modifier.fillMaxWidth()) { week.forEach { dayModel -> Box(modifier = Modifier.weight(1f)) { - DayCell(model = dayModel, type = type) + DayCell( + model = dayModel, + type = type, + isSelected = dayModel.day != null && dayModel.day == selectedDay, + onClick = + onDayClick?.let { click -> + dayModel.day?.let { day -> { click(day) } } + }, + ) } } // 마지막 주가 7개 미만이면 빈 셀로 채우기 diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyQuestionBanner.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyQuestionBanner.kt index 1e18125d80..3b00a3ea93 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyQuestionBanner.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyQuestionBanner.kt @@ -1,6 +1,9 @@ package com.afternote.feature.mindrecord.presentation.component import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -17,6 +20,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -49,6 +53,11 @@ fun DailyQuestionBanner( modifier: Modifier = Modifier, dayNumber: Int? = null, ) { + // 본문 퇴장·간격 축소·화살표 회전이 같은 길이로 함께 움직이도록 값을 맞춘다 (#722). + val transition = updateTransition(targetState = expanded, label = "bannerExpand") + val contentSpacing by transition.animateDp(label = "spacing") { open -> if (open) 8.dp else 0.dp } + val arrowRotation by transition.animateFloat(label = "arrow") { open -> if (open) 180f else 0f } + Column( modifier = modifier @@ -64,7 +73,9 @@ fun DailyQuestionBanner( onDrawBehind { drawRect(brush) } }.clickable(role = Role.Button, onClick = onToggle) .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + // 간격도 같은 전환에 태운다. 종전에는 본문만 AnimatedVisibility 였고 간격과 화살표 + // 회전은 즉시 바뀌어, 접기 동작이 한 흐름으로 이어지지 않고 끊겨 보였다 (#722). + verticalArrangement = Arrangement.spacedBy(contentSpacing), ) { Row( modifier = Modifier.fillMaxWidth(), @@ -102,7 +113,7 @@ fun DailyQuestionBanner( modifier = Modifier .size(16.dp) - .rotate(if (expanded) 180f else 0f), + .rotate(arrowRotation), ) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyQuestionListCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyQuestionListCard.kt index bd53ed0d89..772e1f6d52 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyQuestionListCard.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyQuestionListCard.kt @@ -50,6 +50,8 @@ private val DateFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") fun DailyQuestionListCard( answer: DailyQuestion, modifier: Modifier = Modifier, + /** 카드 전체 탭 — 저장된 기록 본문을 여는 상세 화면으로 간다 (#759). */ + onClick: () -> Unit = {}, onEdit: () -> Unit = {}, onDelete: () -> Unit = {}, ) { @@ -60,7 +62,7 @@ fun DailyQuestionListCard( ), border = BorderStroke(1.dp, color = AfternoteDesign.colors.gray2), shape = CardShape, - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onClick), ) { if (answer.imageUrl != null) { ThumbnailCardContent(answer = answer, onEdit = onEdit, onDelete = onDelete) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DayCell.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DayCell.kt index fd1a9296b7..5235c7ac80 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DayCell.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DayCell.kt @@ -1,6 +1,7 @@ package com.afternote.feature.mindrecord.presentation.component import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -15,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.mindrecord.presentation.model.DayState @@ -26,6 +28,9 @@ fun DayCell( model: DayUiModel, type: MindRecordCategoryUi, modifier: Modifier = Modifier, + /** 사용자가 고른 날. 선택 표시는 오늘 표시와 별개다 (#724). */ + isSelected: Boolean = false, + onClick: (() -> Unit)? = null, ) { if (model.day == null) { Box(modifier = Modifier.aspectRatio(1f)) @@ -35,16 +40,19 @@ fun DayCell( // - TODAY: 검은 원 + 흰 글자 // - ANSWERED(작성한 날): 투명 배경 + 진한 글자 + 하단 인디케이터 (diary 는 이모지, 그 외는 점) // - NONE/UNANSWERED: 투명 배경 + 회색 글자, 인디케이터 없음 + // 선택은 오늘보다 우선한다 — 오늘 칸을 골랐을 때도 "고른 칸" 임이 보여야 한다 (#724). val bgColor = - when (model.state) { - DayState.TODAY -> AfternoteDesign.colors.gray9 + when { + isSelected -> AfternoteDesign.colors.gray9 + model.state == DayState.TODAY -> AfternoteDesign.colors.gray9 else -> Color.Transparent } val textColor = - when (model.state) { - DayState.TODAY -> AfternoteDesign.colors.white - DayState.ANSWERED -> AfternoteDesign.colors.gray9 - DayState.UNANSWERED, DayState.NONE -> AfternoteDesign.colors.gray5 + when { + isSelected -> AfternoteDesign.colors.white + model.state == DayState.TODAY -> AfternoteDesign.colors.white + model.state == DayState.ANSWERED -> AfternoteDesign.colors.gray9 + else -> AfternoteDesign.colors.gray5 } val textStyle = when (model.state) { @@ -56,7 +64,14 @@ fun DayCell( modifier = modifier .aspectRatio(1f) - .padding(4.dp), + .then( + // 날짜 셀에 클릭 콜백이 아예 없어 눌러도 아무 일이 없었다 (#724). + if (onClick == null) { + Modifier + } else { + Modifier.clickable(role = Role.Button, onClick = onClick) + }, + ).padding(4.dp), contentAlignment = Alignment.Center, ) { Box( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryCard.kt index 4cd2138d84..48b54d6a4d 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryCard.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryCard.kt @@ -52,6 +52,8 @@ private val DateFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") fun DiaryCard( diary: DailyDiary, modifier: Modifier = Modifier, + /** 카드 전체 탭 — 저장된 기록 본문을 여는 상세 화면으로 간다 (#759). */ + onClick: () -> Unit = {}, onEdit: () -> Unit = {}, onDelete: () -> Unit = {}, ) { @@ -66,7 +68,8 @@ fun DiaryCard( modifier = modifier .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)), + .clip(RoundedCornerShape(6.dp)) + .clickable(role = Role.Button, onClick = onClick), ) { Column { if (diary.imageUrl != null) { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryComponent.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryComponent.kt index 77125b84df..e5eb2f5925 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryComponent.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryComponent.kt @@ -50,6 +50,8 @@ private val DateFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") fun DiaryComponent( diary: DailyDiary, modifier: Modifier = Modifier, + /** 카드 전체 탭 — 저장된 기록 본문을 여는 상세 화면으로 간다 (#759). */ + onClick: () -> Unit = {}, onEdit: () -> Unit = {}, onDelete: () -> Unit = {}, ) { @@ -61,7 +63,7 @@ fun DiaryComponent( containerColor = AfternoteDesign.colors.white, ), border = BorderStroke(1.dp, color = AfternoteDesign.colors.gray2), - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onClick), shape = RoundedCornerShape(6.dp), ) { Row( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/LinkBottomSheet.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/LinkBottomSheet.kt index a30d79e154..cc4afa4ac9 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/LinkBottomSheet.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/LinkBottomSheet.kt @@ -36,6 +36,7 @@ import com.afternote.core.ui.AfternoteTextField import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.util.isSupportedLinkUrl /** * 키보드 툴바의 링크 버튼에서 호출되는 "링크 추가하기" 바텀시트. @@ -82,6 +83,9 @@ private fun LinkSheetContent( onConfirm: () -> Unit, modifier: Modifier = Modifier, ) { + // core 정본은 TextFieldState 를 들고 있다 — 입력값 판정은 그 상태에서 읽는다 (#634·#722). + val url = urlState.text.toString() + Column( modifier = modifier @@ -107,7 +111,9 @@ private fun LinkSheetContent( color = AfternoteDesign.colors.gray6, ) - ConfirmPill(onClick = onConfirm) + // 형식이 맞을 때만 완료가 눌린다. 종전에는 입력값과 무관하게 항상 활성이라 + // 임의 문자열이 그대로 본문 링크가 됐다 (#722). + ConfirmPill(onClick = onConfirm, enabled = url.isSupportedLinkUrl()) } // core 정본을 쓴다. 종전에는 흰 배경 + gray2 indicator + 8dp 코너를 M3 프리미티브로 @@ -118,24 +124,38 @@ private fun LinkSheetContent( keyboardType = KeyboardType.Uri, modifier = Modifier.fillMaxWidth(), ) + + // 왜 완료가 안 눌리는지 알려 준다 — 회색 버튼만으로는 고장과 구분되지 않는다. + if (url.isNotBlank() && !url.isSupportedLinkUrl()) { + Text( + text = stringResource(R.string.mindrecord_link_sheet_invalid), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + } } } @Composable -private fun ConfirmPill(onClick: () -> Unit) { +private fun ConfirmPill( + onClick: () -> Unit, + enabled: Boolean, +) { Box( modifier = Modifier .clip(CircleShape) - .background(AfternoteDesign.colors.gray2) - .border(BorderStroke(1.dp, AfternoteDesign.colors.gray4), CircleShape) - .clickable(onClick = onClick) + .background(if (enabled) AfternoteDesign.colors.gray2 else AfternoteDesign.colors.gray1) + .border( + BorderStroke(1.dp, if (enabled) AfternoteDesign.colors.gray4 else AfternoteDesign.colors.gray3), + CircleShape, + ).clickable(enabled = enabled, onClick = onClick) .padding(horizontal = 15.dp, vertical = 9.dp), ) { Text( text = stringResource(R.string.mindrecord_link_sheet_confirm), style = AfternoteDesign.typography.captionLargeB, - color = AfternoteDesign.colors.gray9, + color = if (enabled) AfternoteDesign.colors.gray9 else AfternoteDesign.colors.gray4, ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt index a64e0d6622..443e9dd71b 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt @@ -23,12 +23,14 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.SpanStyle @@ -44,11 +46,13 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.model.TextStyleState import com.afternote.feature.mindrecord.presentation.model.TextStyleType +import com.afternote.feature.mindrecord.presentation.util.escapeHtml import com.afternote.feature.mindrecord.presentation.util.mediaDisplayName import com.afternote.feature.mindrecord.presentation.util.mediaImageSize import com.afternote.feature.mindrecord.presentation.util.toUploadedFileKey import com.mohamedrejeb.richeditor.model.rememberRichTextState import com.mohamedrejeb.richeditor.ui.BasicRichTextEditor +import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** @@ -127,10 +131,18 @@ fun WriteTextField( var showTextStyleToolbar by remember { mutableStateOf(false) } var sheet: KeyboardSheet by remember { mutableStateOf(KeyboardSheet.None) } val imeVisible = WindowInsets.isImeVisible + // 지연 판정 시점에도 최신 값을 읽어야 한다 — 람다가 진입 시점 값에 고정되면 안 된다. + val keyboardShown by rememberUpdatedState(imeVisible) val editorFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + // 키보드가 내려가면 패널도 닫는다. 다만 T 로 막 켜서 키보드를 올리는 중인 프레임에는 + // 아직 imeVisible=false 라 그때 끄면 토글이 헛돈다 — 켜는 순간은 건너뛴다 (#722). LaunchedEffect(imeVisible) { - if (!imeVisible) showTextStyleToolbar = false + if (imeVisible || !showTextStyleToolbar) return@LaunchedEffect + // 키보드가 올라올 시간을 준 뒤에도 여전히 없으면 그때 닫는다. + delay(KEYBOARD_SETTLE_MS) + if (!keyboardShown) showTextStyleToolbar = false } fun keepEditorFocus(action: () -> Unit) { @@ -280,7 +292,18 @@ fun WriteTextField( BottomToolbar( modifier = Modifier.imePadding(), - onTextStyleClick = { showTextStyleToolbar = !showTextStyleToolbar }, + onTextStyleClick = { + // 키보드가 없으면 패널이 렌더되지 않으므로 포커스와 키보드를 먼저 되살린다. + // 종전에는 T 를 눌러도 토글만 뒤집혔다가 imeVisible=false 라 즉시 초기화돼 + // 아무 일도 일어나지 않았다 (#722). + if (showTextStyleToolbar) { + showTextStyleToolbar = false + } else { + showTextStyleToolbar = true + runCatching { editorFocusRequester.requestFocus() } + keyboardController?.show() + } + }, onAlignChange = { align -> keepEditorFocus { state.addParagraphStyle(ParagraphStyle(textAlign = align)) } }, @@ -319,8 +342,11 @@ fun WriteTextField( LinkBottomSheet( onDismiss = { sheet = KeyboardSheet.None }, onConfirm = { url -> + // 검증을 통과한 값이라도 속성에 넣기 전에 이스케이프한다 — 따옴표나 + // 꺾쇠가 섞이면 a 태그를 깨고 나올 수 있다 (#722). + val safeUrl = url.trim().escapeHtml() keepEditorFocus { - state.setHtml(state.toHtml() + "$url") + state.setHtml(state.toHtml() + "$safeUrl") } sheet = KeyboardSheet.None }, @@ -362,6 +388,9 @@ private fun WriteTextFieldPreview() { } } +/** T 로 키보드를 올릴 때 IME 가 올라오기를 기다리는 시간 (#722). */ +private const val KEYBOARD_SETTLE_MS = 300L + /** 에디터 본문 이미지의 가로 기준 크기(px). 높이는 원본 비율로 계산한다 (#731). */ private const val MEDIA_IMAGE_WIDTH_PX = 320 diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt index 9b7f5278a7..d4e1b545aa 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt @@ -31,6 +31,13 @@ interface MindRecordNavActions { fun onDraftListBack() + /** 목록 항목 탭 — 저장된 기록 본문을 여는 상세 화면 (#759). */ + fun onOpenRecordDetail( + recordId: Long, + isDiary: Boolean, + yearMonth: String?, + ) + /** 목록의 "수정하기" — 정식 데일리질문 답변을 프리필한 작성 화면으로 연다 (#582). */ fun onEditDailyQuestion(answerId: Long) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt index c84ffbc6f0..fe7dddd166 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt @@ -2,6 +2,7 @@ package com.afternote.feature.mindrecord.presentation.navigation import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +import androidx.navigation.toRoute import com.afternote.core.ui.Route import com.afternote.feature.mindrecord.presentation.model.MindRecordCategoryUi import com.afternote.feature.mindrecord.presentation.screen.memoryspace.MemorySpaceScreen @@ -10,6 +11,7 @@ import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestion import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryWriteScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DraftListScreen import com.afternote.feature.mindrecord.presentation.screen.sender.HomeScreen +import com.afternote.feature.mindrecord.presentation.screen.sender.RecordDetailScreen import java.time.YearMonth /** @@ -21,6 +23,15 @@ import java.time.YearMonth fun NavGraphBuilder.mindRecordNavGraph(actions: MindRecordNavActions) { composable { HomeScreen( + onRecordClick = { recordId, isDiary, yearMonth -> + // 목록이 보고 있던 달을 그대로 넘긴다. 이번 달로 고정하면 지난달 기록이 + // 상세에서 조회되지 않아 통째로 열리지 않는다 (#759 리뷰). + actions.onOpenRecordDetail( + recordId = recordId, + isDiary = isDiary, + yearMonth = yearMonth.toString(), + ) + }, onWriteClick = { category -> when (category) { MindRecordCategoryUi.DailyQuestion -> actions.onWriteDailyQuestion() @@ -56,6 +67,12 @@ fun NavGraphBuilder.mindRecordNavGraph(actions: MindRecordNavActions) { onDraftListClick = actions::onNavigateToDraftList, ) } + composable { entry -> + RecordDetailScreen( + isDiary = entry.toRoute().isDiary, + onBackClick = actions::onWriteBack, + ) + } composable { DraftListScreen( onBackClick = actions::onDraftListBack, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordRoute.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordRoute.kt index 921c232c4b..ed352bbc1e 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordRoute.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordRoute.kt @@ -33,6 +33,19 @@ sealed interface MindRecordRoute { val isDraft: Boolean = true, ) : MindRecordRoute + /** + * 기록 상세(열람) 화면 (#759). + * + * 데일리질문과 일기가 같은 화면을 쓴다 — 시안 4종의 차이는 첨부 이미지 유무와 + * 기록 종류뿐이다. [yearMonth](`yyyy-MM`)는 일기를 찾을 달이다. + */ + @Serializable + data class RecordDetailRoute( + val recordId: Long, + val isDiary: Boolean, + val yearMonth: String? = null, + ) : MindRecordRoute + /** 작성 화면 키보드 툴바의 "임시저장 N" 영역에서 진입하는 임시저장 목록 화면. */ @Serializable data object DraftListRoute : MindRecordRoute diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt index e8d2b2683b..12030c62da 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.asString import com.afternote.core.ui.theme.AfternoteDesign @@ -38,13 +40,25 @@ import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionList import com.afternote.feature.mindrecord.presentation.viewmodel.TodayQuestionUi import java.time.YearMonth +/** + * @param onItemClick 카드를 눌렀을 때 상세로 보낼 기록 ID 와 **보고 있는 달** (#759 리뷰). + * 기본값을 두지 않는다 — + * 기본값이 있으면 호출부에서 빠뜨려도 컴파일이 되고, 카드가 눌리지 않는 채로 나간다 (#759). + */ @Composable fun DailyQuestionAnswerListScreen( + onItemClick: (Long, YearMonth) -> Unit, modifier: Modifier = Modifier, isListView: Boolean = true, onEditClick: (Long) -> Unit = {}, viewModel: DailyQuestionListViewModel = hiltViewModel(), ) { + // 갱신을 이 화면이 직접 건다. HomeScreen 이 VM 을 호이스팅해 대신 걸어 주면, 탭에 + // 들어가지 않아도 VM 이 만들어져 `init` 조회가 미리 나간다 (#736). + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.refreshOnReturn() + } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() var pendingDeleteId by remember { mutableStateOf(null) } @@ -90,6 +104,7 @@ fun DailyQuestionAnswerListScreen( isListView = isListView, todayQuestion = state.todayQuestion, answers = state.answers, + onItemClick = onItemClick, onEdit = onEditClick, // 삭제는 되돌릴 수 없다 — 종전에는 메뉴를 누르는 즉시 실행됐다 (#582). onDelete = { pendingDeleteId = it }, @@ -105,13 +120,33 @@ private fun DailyQuestionListContent( answers: List, modifier: Modifier = Modifier, todayQuestion: TodayQuestionUi? = null, + /** 항목 탭 — 저장된 기록 본문을 여는 상세 화면 (#759). */ + onItemClick: (Long, YearMonth) -> Unit = { _, _ -> }, + /** «수정하기» — 정식 답변을 프리필한 작성 화면으로 (#582). */ onEdit: (Long) -> Unit = {}, onDelete: (Long) -> Unit = {}, ) { var yearMonth by remember { mutableStateOf(YearMonth.now()) } val onYearMonthChanged: (YearMonth) -> Unit = { yearMonth = it } + // 월이 바뀌면 그 달의 선택은 무효다. + var selectedDay by remember(yearMonth) { mutableStateOf(null) } + + // 캘린더 형에서는 **선택한 월** 기준으로 카드도 함께 좁힌다. 종전에는 캘린더 점만 + // 필터하고 카드는 전체 기간을 그대로 렌더해, 8월 캘린더가 "0개의 답변 완료" 인데 + // 아래에 7월 답변이 남아 있었다 (#724). + val monthAnswers = + if (isListView) { + answers + } else { + answers.filter { it.date.year == yearMonth.year && it.date.monthValue == yearMonth.monthValue } + } + val visibleAnswers = + selectedDay?.let { day -> monthAnswers.filter { it.date.dayOfMonth == day } } ?: monthAnswers + var questionExpanded by remember { mutableStateOf(true) } + // 리스트 형에서 전체가 비었을 때만 캘린더 없이 빈 상태를 보여준다. 캘린더 형에서는 + // 빈 상태가 캘린더를 대체하면 월 이동 수단이 사라진다 (#724). // 답변이 0건이어도 오늘의 추천 질문은 보여야 한다 — 종전에는 빈 상태가 화면 전체를 // 대체해 이 영역까지 함께 사라졌다 (#592). if (isListView && answers.isEmpty()) { @@ -144,11 +179,7 @@ private fun DailyQuestionListContent( // Figma 2671:16704 — 캘린더 형은 캘린더 아래에 카드 리스트가 바로 이어짐 (gap 24) if (!isListView) { - val answeredDays = - answers - .filter { it.date.year == yearMonth.year && it.date.monthValue == yearMonth.monthValue } - .map { it.date.dayOfMonth } - .toSet() + val answeredDays = monthAnswers.map { it.date.dayOfMonth }.toSet() item { DailyCalendar( year = yearMonth.year, @@ -157,14 +188,22 @@ private fun DailyQuestionListContent( onPrevMonth = { onYearMonthChanged(yearMonth.minusMonths(1)) }, onNextMonth = { onYearMonthChanged(yearMonth.plusMonths(1)) }, answeredDays = answeredDays, + selectedDay = selectedDay, + // 같은 날을 다시 누르면 선택을 푼다 — 그 달 전체로 돌아올 수단이 필요하다. + onDayClick = { day -> selectedDay = if (selectedDay == day) null else day }, ) Spacer(modifier = Modifier.height(16.dp)) } } // Figma 2757:16116 — 리스트 형은 답변 카드만 나열 - items(answers, key = { it.id }) { answer -> + if (visibleAnswers.isEmpty()) { + item { MindRecordEmptyState() } + } + + items(visibleAnswers, key = { it.id }) { answer -> DailyQuestionListCard( answer = answer, + onClick = { onItemClick(answer.id, yearMonth) }, onEdit = { onEdit(answer.id) }, onDelete = { onDelete(answer.id) }, ) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt index 843f3edd4b..5bd494e3d9 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt @@ -19,12 +19,17 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteSectionHeader import com.afternote.core.ui.asString @@ -47,8 +52,15 @@ import androidx.compose.foundation.lazy.staggeredgrid.items as gridItems private val PreviewYearMonth = YearMonth.of(2026, 7) +/** + * @param onItemClick 카드를 눌렀을 때 상세로 보낼 기록 ID 와 **보고 있는 달**. 달을 함께 + * 싣지 않으면 상세가 이번 달 목록에서 그 기록을 찾다 실패한다 — 목록은 달을 바꿀 수 + * 있으므로 지난달 기록이 통째로 열리지 않는다 (#759 리뷰). 기본값을 두지 않는다 — + * 기본값이 있으면 호출부에서 빠뜨려도 컴파일이 되고, 카드가 눌리지 않는 채로 나간다 (#759). + */ @Composable fun DiaryScreen( + onItemClick: (Long, YearMonth) -> Unit, modifier: Modifier = Modifier, isListView: Boolean = true, /** @@ -58,6 +70,12 @@ fun DiaryScreen( onEditClick: (Long, YearMonth) -> Unit = { _, _ -> }, viewModel: DiaryListViewModel = hiltViewModel(), ) { + // 갱신을 이 화면이 직접 건다. HomeScreen 이 VM 을 호이스팅해 대신 걸어 주면, 탭에 + // 들어가지 않아도 VM 이 만들어져 `init` 조회가 미리 나간다 (#736). + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.refreshOnReturn() + } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() when (val state = uiState) { @@ -94,6 +112,7 @@ fun DiaryScreen( yearMonth = state.yearMonth, monthDiaryCount = state.monthDiaryCount, weeklyMoodEmoji = state.weeklyDominantMood?.toEmoji(), + onItemClick = onItemClick, onEdit = onEditClick, onDelete = viewModel::delete, onYearMonthChanged = viewModel::selectYearMonth, @@ -114,19 +133,16 @@ internal fun DiaryListContent( modifier: Modifier = Modifier, monthDiaryCount: Int = 0, weeklyMoodEmoji: String? = null, - /** «수정하기» — 기록 ID 와 이 화면이 보고 있는 달. 달은 여기서만 알 수 있다. */ + /** 항목 탭 — 저장된 기록 본문을 여는 상세 화면 (#759). */ + onItemClick: (Long, YearMonth) -> Unit = { _, _ -> }, + /** «수정하기» — 기록 ID 와 이 화면이 보고 있는 달. 달은 여기서만 알 수 있다 (#582). */ onEdit: (Long, YearMonth) -> Unit = { _, _ -> }, onDelete: (Long) -> Unit = {}, onYearMonthChanged: (YearMonth) -> Unit = {}, ) { - if (isListView && diaries.isEmpty()) { - MindRecordEmptyState( - modifier = modifier, - title = stringResource(R.string.mindrecord_diary_empty_state_title), - description = stringResource(R.string.mindrecord_diary_empty_state_description), - ) - return - } + // 기록이 없다고 조기 반환하지 않는다. 종전에는 빈 상태가 캘린더를 통째로 대체해 + // 월 이동 버튼까지 사라졌고, 기록이 있는 달로 돌아갈 방법이 없었다 (#724). + var selectedDay by remember(yearMonth) { mutableStateOf(null) } val currentMonthDiaries = diaries.filter { it.date.year == yearMonth.year && it.date.monthValue == yearMonth.monthValue } @@ -136,6 +152,11 @@ internal fun DiaryListContent( .mapNotNull { diary -> diary.emotion?.let { diary.date.dayOfMonth to it } } .toMap() + // 날짜를 고르면 그 날 기록만, 안 고르면 그 달 전체를 보여준다. + val visibleDiaries = + selectedDay?.let { day -> currentMonthDiaries.filter { it.date.dayOfMonth == day } } + ?: currentMonthDiaries + if (isListView) { LazyColumn(modifier = modifier) { item { @@ -147,6 +168,9 @@ internal fun DiaryListContent( onNextMonth = { onYearMonthChanged(yearMonth.plusMonths(1)) }, answeredDays = answeredDays, emotionByDay = emotionByDay, + selectedDay = selectedDay, + // 같은 날을 다시 누르면 선택을 푼다 — 그 달 전체로 돌아올 수단이 필요하다. + onDayClick = { day -> selectedDay = if (selectedDay == day) null else day }, ) Spacer(modifier = Modifier.height(10.dp)) } @@ -158,10 +182,20 @@ internal fun DiaryListContent( Spacer(modifier = Modifier.height(10.dp)) } - items(diaries, key = { it.id }) { diary -> + if (visibleDiaries.isEmpty()) { + item { + MindRecordEmptyState( + title = stringResource(R.string.mindrecord_diary_empty_state_title), + description = stringResource(R.string.mindrecord_diary_empty_state_description), + ) + } + } + + items(visibleDiaries, key = { it.id }) { diary -> DiaryComponent( onEdit = { onEdit(diary.id, yearMonth) }, diary = diary, + onClick = { onItemClick(diary.id, yearMonth) }, modifier = Modifier.padding(vertical = 8.dp), onDelete = { onDelete(diary.id) }, ) @@ -186,6 +220,7 @@ internal fun DiaryListContent( DiaryCard( onEdit = { onEdit(diary.id, yearMonth) }, diary = diary, + onClick = { onItemClick(diary.id, yearMonth) }, onDelete = { onDelete(diary.id) }, ) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt index 7f72ff0a8c..b3259d8c71 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt @@ -133,14 +133,31 @@ internal fun DiaryWriteScreenContent( title = stringResource(MindRecordR.string.mindrecord_diary_write_title), onBackClick = onBackClick, actions = { + // 완성되지 않아도 **누를 수는 있게** 둔다. 비활성이면 submit() 이 아예 + // 돌지 않아 무엇이 빠졌는지 알릴 자리가 없다 — 회색 버튼만으로는 고장과 + // 구분되지 않는다 (#722). 색은 종전대로 미완성일 때 흐리게 둔다. Button( onClick = onSubmit, - enabled = uiState.canSubmit, + // 완성되지 않아도 **누를 수는 있게** 둔다 — `canSubmit` 이 아닌 이유다. + // 비활성이면 submit() 이 아예 돌지 않아 무엇이 빠졌는지 알릴 자리가 없고, + // 회색 버튼만으로는 고장과 구분되지 않는다 (#722). 색은 아래에서 미완성일 + // 때 흐리게 둔다. + enabled = uiState.submitState != SubmitState.InProgress, shape = RoundedCornerShape(6.dp), colors = ButtonDefaults.buttonColors( - containerColor = AfternoteDesign.colors.gray9, - contentColor = AfternoteDesign.colors.white, + containerColor = + if (uiState.canSubmit) { + AfternoteDesign.colors.gray9 + } else { + AfternoteDesign.colors.gray2 + }, + contentColor = + if (uiState.canSubmit) { + AfternoteDesign.colors.white + } else { + AfternoteDesign.colors.gray6 + }, disabledContainerColor = AfternoteDesign.colors.gray2, disabledContentColor = AfternoteDesign.colors.gray6, ), 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 ebf1252990..ca163dcd4d 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 @@ -17,6 +17,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier @@ -38,20 +40,17 @@ import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionList import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryListViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.WeeklyReportViewModel import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch import java.time.YearMonth import com.afternote.feature.mindrecord.presentation.R as MindRecordR @Composable fun HomeScreen( modifier: Modifier = Modifier, - dailyQuestionViewModel: DailyQuestionListViewModel = hiltViewModel(), - diaryViewModel: DiaryListViewModel = hiltViewModel(), - // WeeklyReportScreen 과 같은 NavBackStackEntry 를 owner 로 쓰므로 동일 인스턴스다. - // 여기서 호이스팅하면 주간리포트 탭에 들어가지 않아도 VM 의 init { load(...) } 가 돌아 - // 마음의 기록 탭 진입마다 요약 조회가 한 번 나간다. 대신 탭을 열면 스피너 없이 즉시 보이는 - // 프리페치가 되므로 트레이드오프를 받아들인다. - weeklyReportViewModel: WeeklyReportViewModel = hiltViewModel(), onWriteClick: (MindRecordCategoryUi) -> Unit = {}, + // 목록 항목 탭 → 상세(열람) 화면. (기록 ID, 일기 여부, 목록이 보고 있던 달) (#759). + onRecordClick: (Long, Boolean, YearMonth) -> Unit = { _, _, _ -> }, + // 목록의 «수정하기» → 프리필한 작성 화면 (#582). onEditDailyQuestion: (Long) -> Unit = {}, onEditDiary: (Long, YearMonth) -> Unit = { _, _ -> }, ) { @@ -65,52 +64,35 @@ fun HomeScreen( ) } - var isListView by remember { mutableStateOf(true) } - var selectedIndex by remember { mutableIntStateOf(0) } - val selectedCategory = categories[selectedIndex] - + // 선택 상태의 단일 출처는 pager 다. 종전에는 selectedIndex 로 애니메이션을 시작하면서 + // pagerState.currentPage 를 다시 selectedIndex 에 기록해, 0↔2 전환 중 거쳐 가는 1번 + // 페이지가 선택값을 덮어써 가운데 탭(일기)에 멈췄다 (#722). + // + // 탭 클릭은 pager 에게 "거기로 가라" 고만 말하고, 화면이 읽는 값은 pager 의 + // settledPage 하나뿐이다 — 스크롤 도중의 중간 페이지가 선택으로 굳지 않는다. val pagerState = rememberPagerState { categories.size } - LaunchedEffect(selectedIndex) { - pagerState.animateScrollToPage(selectedIndex) - } - LaunchedEffect(pagerState.currentPage) { - selectedIndex = pagerState.currentPage - } + val selectedIndex = pagerState.settledPage + val selectedCategory = categories[selectedIndex] + val scope = rememberCoroutineScope() - // 탭 전환과 ON_RESUME 은 성격이 같은 자동 갱신이므로 대상도 같아야 한다. - // 한쪽에만 주간리포트가 빠지면, 일기를 쓰고 돌아와 주간리포트 탭으로 넘겼을 때 - // 방금 쓴 일기가 주간 집계에 잡히지 않는다. - val refreshTab: (MindRecordCategoryUi) -> Unit = { category -> - when (category) { - MindRecordCategoryUi.DailyQuestion -> dailyQuestionViewModel.refreshOnReturn() - MindRecordCategoryUi.Diary -> diaryViewModel.refreshOnReturn() - MindRecordCategoryUi.WeeklyReport -> weeklyReportViewModel.refreshOnReturn() + // 탭마다 따로 기억한다. 종전에는 한 값을 두 탭이 공유했는데 의미가 서로 반대였다 — + // 데일리질문은 false 를 캘린더로, 일기는 같은 false 를 2열 그리드로 썼다. 그래서 탭을 + // 오가면 아이콘은 그대로인데 표시가 뒤바뀐 것처럼 보였다 (#724). + var dailyQuestionListView by rememberSaveable { mutableStateOf(true) } + var diaryListView by rememberSaveable { mutableStateOf(true) } + val isListView = + when (selectedCategory) { + MindRecordCategoryUi.DailyQuestion -> dailyQuestionListView + else -> diaryListView } - } - LaunchedEffect(Unit) { - snapshotFlow { selectedIndex } - .drop(1) - .collect { index -> refreshTab(categories[index]) } - } - - // ON_RESUME 은 작성 화면 복귀뿐 아니라 화면 off/on · 홈 버튼 후 복귀 · 권한 다이얼로그 - // 닫기에서도 발화한다. 화면이 살아 있는 채로 발화하므로 refreshOnReturn() 을 쓴다 — - // 로딩을 방출하지 않아 캘린더 월·스크롤 위치가 살아남고, 실패해도 보고 있던 화면을 유지한다. - // - // 최초 진입의 중복 호출은 각 VM 이 진행 중인 로드 Job 으로 막는다. 컴포지션 쪽 플래그로 - // 막으면 수명이 어긋난다 — rememberSaveable 은 SavedState 수명이고 막으려는 init { load() } - // 는 ViewModelStore 수명이라, 프로세스 사망 후 복원에서 플래그는 false 로 살아 돌아오는데 - // VM 은 새로 만들어져 같은 조회가 두 번 나간다. + // 탭 VM 은 **여기서 만들지 않는다.** 호이스팅하면 선택하지 않은 탭의 `init` 조회까지 + // 진입 즉시 나간다 — 마음의 기록 첫 진입 한 번에 요청이 7건 나간 가장 큰 원인이었다. + // 특히 주간리포트는 서버가 같은 주차의 반복 GET 에도 Gemini 를 다시 호출하므로 + // (Afternote-BE#118), 열지도 않은 탭의 프리페치가 모델 호출 비용으로 이어진다 (#736). // - // selectedCategory 는 State 가 아니라 매 컴포지션 새로 계산되는 지역 val 이지만 여기서는 - // 최신 값이 읽힌다 — LifecycleEventEffect 가 onEvent 를 rememberUpdatedState 로 감싸 - // 옵저버에 넘기기 때문이다 (lifecycle-runtime-compose 2.11.0 LifecycleEffect.kt:66). - // LifecycleResumeEffect 는 effects 람다를 DisposableEffect(lifecycleOwner, scope) 안에서 - // 직접 캡처해 진입 시점 값에 고정되므로, 이 화면에서는 쓰면 안 된다. - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - refreshTab(selectedCategory) - } + // 대신 각 화면이 스스로 `ON_RESUME` 갱신을 건다 — 탭에 들어가야 컴포즈되므로 + // 그 시점이 곧 "처음 필요한 시점" 이다. Scaffold( modifier = modifier, @@ -126,7 +108,12 @@ fun HomeScreen( isListView = isListView, image1 = R.drawable.core_ui_list, image2 = R.drawable.core_ui_calendar, - onViewChange = { isListView = it }, + onViewChange = { listView -> + when (selectedCategory) { + MindRecordCategoryUi.DailyQuestion -> dailyQuestionListView = listView + else -> diaryListView = listView + } + }, ) } }, @@ -168,7 +155,11 @@ fun HomeScreen( categories.forEachIndexed { index, category -> Tab( selected = selectedIndex == index, - onClick = { selectedIndex = index }, + // scrollToPage 는 중간 페이지를 거치지 않는다. animate 로 넘기면 + // 0 → 2 이동이 1번을 지나며 그 화면이 컴포즈되고, hiltViewModel() 이 + // VM 을 만들며 init 조회가 나간다 — 바로 아래 단(#736)이 «열지도 않은 + // 탭의 조회를 없앤다» 로 줄여 둔 요청이 탭 이동마다 되살아난다. + onClick = { scope.launch { pagerState.scrollToPage(index) } }, text = { Text( text = stringResource(category.titleRes), @@ -181,18 +172,22 @@ fun HomeScreen( Spacer(modifier = Modifier.height(20.dp)) - HorizontalPager(state = pagerState) { _ -> - when (selectedCategory) { + // page 인자를 실제로 쓴다 — 종전에는 무시하고 전역 selectedCategory 만 렌더해 + // 스와이프 중에도 같은 화면이 보였다 (#722). + HorizontalPager(state = pagerState) { page -> + when (categories[page]) { MindRecordCategoryUi.DailyQuestion -> { DailyQuestionAnswerListScreen( - isListView = isListView, + isListView = dailyQuestionListView, + onItemClick = { id, yearMonth -> onRecordClick(id, false, yearMonth) }, onEditClick = onEditDailyQuestion, ) } MindRecordCategoryUi.Diary -> { DiaryScreen( - isListView = isListView, + isListView = diaryListView, + onItemClick = { id, yearMonth -> onRecordClick(id, true, yearMonth) }, onEditClick = onEditDiary, ) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/RecordDetailScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/RecordDetailScreen.kt new file mode 100644 index 0000000000..44b70fad9d --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/RecordDetailScreen.kt @@ -0,0 +1,281 @@ +package com.afternote.feature.mindrecord.presentation.screen.sender + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.afternote.core.ui.asString +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.util.RecordContentBlock +import com.afternote.feature.mindrecord.presentation.viewmodel.RecordDetailUiState +import com.afternote.feature.mindrecord.presentation.viewmodel.RecordDetailViewModel +import java.time.format.DateTimeFormatter + +/** + * 기록 상세(열람) 화면 (#759). + * + * Figma 3814:18721 / 18739 / 18796 / 18813 — 시안 4종은 두 축의 조합이다. + * **첨부 이미지 유무**(헤더 배경과 글자색이 바뀐다)와 **기록 종류**(일기에만 오늘의 기분 줄). + * 그래서 화면은 하나만 두고 상태로 가른다. + */ +@Composable +fun RecordDetailScreen( + modifier: Modifier = Modifier, + viewModel: RecordDetailViewModel = hiltViewModel(), + isDiary: Boolean = false, + onBackClick: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Scaffold( + modifier = modifier, + containerColor = AfternoteDesign.colors.gray1, + topBar = { + DetailTopBar( + title = + stringResource( + if (isDiary) { + R.string.mindrecord_detail_title_diary + } else { + R.string.mindrecord_detail_title_daily_question + }, + ), + onBackClick = onBackClick, + ) + }, + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + when (val state = uiState) { + RecordDetailUiState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + + RecordDetailUiState.NotFound -> { + // 통신 실패와 구분한다 — 여기서는 재시도가 의미 없고 목록으로 돌아가야 한다. + Box( + modifier = Modifier.fillMaxSize().padding(20.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.mindrecord_detail_not_found), + color = AfternoteDesign.colors.gray9, + ) + } + } + + is RecordDetailUiState.Error -> { + Box( + modifier = Modifier.fillMaxSize().padding(20.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = state.message.asString(), color = AfternoteDesign.colors.gray9) + } + } + + is RecordDetailUiState.Success -> { + DetailContent(state = state) + } + } + } + } +} + +@Composable +private fun DetailContent(state: RecordDetailUiState.Success) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { DetailHero(state = state) } + + item { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(top = 24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + // 시안은 본문을 «문단 → 이미지 → 문단» 처럼 섞어서 보여준다. + state.blocks.forEach { block -> + when (block) { + is RecordContentBlock.Text -> { + Text( + text = block.text, + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray9, + ) + } + + is RecordContentBlock.Image -> { + AsyncImage( + model = block.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .height(CONTENT_IMAGE_HEIGHT) + .clip(RoundedCornerShape(5.dp)) + .background(AfternoteDesign.colors.gray2), + ) + } + } + } + + state.mood?.let { MoodRow(emoji = it) } + } + } + } +} + +/** + * 헤더 밴드 — 시안의 «이미지 O / X» 를 가르는 자리. + * + * 사진이 있으면 사진 위에 아래로 갈수록 짙어지는 검은 그라데이션을 얹고 글자를 밝게 쓴다. + * 없으면 옅은 그라데이션을 깔고 글자를 어둡게 쓴다 — 배경이 밝아지므로 반대로 뒤집힌다. + */ +@Composable +private fun DetailHero(state: RecordDetailUiState.Success) { + val hasImage = state.heroImageUrl != null + val titleColor = if (hasImage) AfternoteDesign.colors.white else AfternoteDesign.colors.gray9 + val metaColor = if (hasImage) AfternoteDesign.colors.gray3 else AfternoteDesign.colors.gray8 + + Box(modifier = Modifier.fillMaxWidth().height(HERO_HEIGHT)) { + if (hasImage) { + AsyncImage( + model = state.heroImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + Box( + modifier = + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.4f)), + ), + ), + ) + } else { + Box( + modifier = + Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + colors = listOf(EmptyHeroCenter, EmptyHeroEdge), + center = Offset.Unspecified, + ), + ), + ) + } + + Column( + modifier = Modifier.align(Alignment.BottomStart).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = state.title, style = AfternoteDesign.typography.h2, color = titleColor) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // 수신자를 지정하지 않은 기록에는 이 줄 자체를 만들지 않는다. + if (state.receiverNames.isNotEmpty()) { + Text( + text = + stringResource(R.string.mindrecord_detail_receiver) + + " " + state.receiverNames.joinToString(", "), + style = AfternoteDesign.typography.footnoteCaption, + color = metaColor, + ) + } else { + Box(modifier = Modifier) + } + // 날짜를 못 구하면 줄을 그리지 않는다 — 지어낸 날짜를 그리면 사용자는 그 + // 기록이 실제로 그 날 쓰인 것으로 읽는다 (#759 리뷰). + state.date?.let { date -> + Text( + text = date.format(DetailDateFormatter), + style = AfternoteDesign.typography.footnoteCaption, + color = metaColor, + ) + } + } + } + } +} + +@Composable +private fun MoodRow(emoji: String) { + Row( + modifier = Modifier.fillMaxWidth().height(32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // 시안은 라벨 앞에 16dp 아이콘을 둔다 — 작성 화면과 같은 표정 아이콘이다. + Icon( + painter = painterResource(R.drawable.mindrecord_emotion), + contentDescription = null, + tint = AfternoteDesign.colors.gray6, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.mindrecord_detail_mood), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + Spacer(modifier = Modifier.width(12.dp)) + Box( + modifier = + Modifier + .size(32.dp) + .clip(CircleShape) + .background(AfternoteDesign.colors.gray2), + contentAlignment = Alignment.Center, + ) { + Text(text = emoji, style = AfternoteDesign.typography.bodyLargeB) + } + } +} + +private val HERO_HEIGHT = 160.dp +private val CONTENT_IMAGE_HEIGHT = 248.dp + +/** 시안 표기 — `2024.03.21.` (끝의 마침표 포함). */ +private val DetailDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd.") + +/** 이미지가 없을 때의 헤더 그라데이션 (시안 실측). */ +private val EmptyHeroCenter = Color(0xFFB7CDC0) +private val EmptyHeroEdge = Color(0xFFF8F8F7) 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 f834ef021a..37f0c5bb29 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 @@ -23,6 +23,8 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteSectionHeader import com.afternote.core.ui.asString @@ -45,6 +47,15 @@ fun WeeklyReportScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + // 갱신을 이 화면이 직접 건다. HomeScreen 이 VM 을 호이스팅해 대신 걸어 주면, 탭에 + // 들어가지 않아도 VM 이 만들어져 `init` 조회가 미리 나간다 (#736). + // + // 첫 ON_RESUME 을 건너뛰는 판단은 ViewModel 이 한다 — 화면 저장 상태에 두면 프로세스 + // 사망 뒤 «지나갔음» 만 복원돼 새 ViewModel 의 init 과 겹친다 (#736 리뷰). + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.refreshOnReturn() + } + when (val state = uiState) { WeeklyReportUiState.Loading -> { LoadingBox(modifier) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt index 267c3cf8b5..ce054198d7 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt @@ -1,6 +1,7 @@ package com.afternote.feature.mindrecord.presentation.util import androidx.core.text.HtmlCompat +import java.net.URI /** * HTML 직렬화된 본문에서 태그를 제거해 리스트 카드에 노출할 plain text 로 변환. @@ -111,3 +112,87 @@ private val HTML_MEDIA_TAG = Regex("""<(img|video|audio|iframe|embed)\b""", Rege /** 공백으로 렌더되는 엔티티. 나머지는 가시 문자로 본다. */ private val HTML_SPACE_ENTITIES = setOf(" ", " ", " ", " ", " ") + +/** + * 본문 링크로 쓸 수 있는 URL 인지 (#722). + * + * 종전에는 임의 문자열도 그대로 `href` 가 됐다. 스킴을 http/https 로 제한하고 호스트가 + * 있는지까지 본다 — `javascript:` 같은 스킴이 본문에 들어갈 자리를 없앤다. + */ +fun String.isSupportedLinkUrl(): Boolean { + val trimmed = trim() + if (trimmed.isEmpty() || trimmed.any { it.isWhitespace() }) return false + val parsed = runCatching { URI(trimmed) }.getOrNull() ?: return false + val scheme = parsed.scheme?.lowercase() ?: return false + if (scheme != "http" && scheme != "https") return false + return !parsed.host.isNullOrBlank() +} + +/** + * HTML 속성·본문에 넣기 전 이스케이프 (#722). + * + * 검증을 통과한 URL 이라도 따옴표나 꺾쇠가 섞이면 `a` 태그를 깨고 나올 수 있다. + */ +fun String.escapeHtml(): String = + buildString(length) { + this@escapeHtml.forEach { ch -> + when (ch) { + '&' -> append("&") + '<' -> append("<") + '>' -> append(">") + '"' -> append(""") + '\'' -> append("'") + else -> append(ch) + } + } + } + +/** + * 상세 화면 본문 블록 (#759). + * + * 시안은 본문을 «문단 → 이미지 → 문단» 처럼 **섞어서** 보여준다. 본문은 HTML 조각이고 + * 이미지는 그 안의 `img` 태그이므로, 태그를 기준으로 잘라 순서대로 그린다. + */ +sealed interface RecordContentBlock { + data class Text( + val text: String, + ) : RecordContentBlock + + data class Image( + val url: String, + ) : RecordContentBlock +} + +/** + * 본문 HTML 을 [RecordContentBlock] 목록으로 자른다. + * + * 리치 에디터가 `` 를 렌더하지 못해(#731) 에디터를 그대로 재사용할 수 없다. 대신 + * 이미지 태그를 경계로 잘라 텍스트는 태그를 벗겨 그리고 이미지는 따로 그린다. + * + * 빈 텍스트 조각은 버린다 — 이미지 앞뒤의 빈 문단이 그대로 빈 줄이 되면 안 된다. + */ +fun String.toRecordContentBlocks(): List { + val blocks = mutableListOf() + var cursor = 0 + HTML_IMG_TAG.findAll(this).forEach { match -> + appendTextBlock(blocks, substring(cursor, match.range.first)) + match.groupValues[1] + .trim() + .takeIf { it.isNotEmpty() } + ?.let { blocks += RecordContentBlock.Image(it) } + cursor = match.range.last + 1 + } + appendTextBlock(blocks, substring(cursor)) + return blocks +} + +private fun appendTextBlock( + blocks: MutableList, + rawHtml: String, +) { + if (rawHtml.isHtmlBlank()) return + val text = rawHtml.htmlToPlainText() + if (text.isNotBlank()) blocks += RecordContentBlock.Text(text) +} + +private val HTML_IMG_TAG = Regex("""]*?\bsrc\s*=\s*["']([^"']*)["'][^>]*>""", RegexOption.IGNORE_CASE) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt index b39851a71e..8be88d2955 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt @@ -6,6 +6,7 @@ import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.DailyQuestion import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.mapper.toUi import dagger.hilt.android.lifecycle.HiltViewModel @@ -25,6 +26,7 @@ class DailyQuestionListViewModel @Inject constructor( private val repository: DailyQuestionRepository, + private val changeTracker: MindRecordChangeTracker, ) : ViewModel() { private val internalState = MutableStateFlow(InternalState()) private var loadJob: Job? = null @@ -43,16 +45,26 @@ class DailyQuestionListViewModel } /** - * 탭 전환·작성 화면 복귀 등 사용자가 요청하지 않은 자동 갱신. + * 마지막으로 **완전히** 성공한 조회 시점의 데이터 버전. 아직 없으면 null. * - * 화면이 살아 있는 채로 발화하므로 로딩을 방출하지 않고(콘텐츠가 통째 교체되면 - * 캘린더 월과 스크롤 위치가 함께 폐기된다) 실패해도 보고 있던 화면을 유지한다. + * today 나 목록 중 하나라도 못 받아 온 로드는 여기 기록하지 않는다 — 기록하면 + * 못 받아 온 채로 복귀 재조회가 막힌다. + */ + private var loadedVersion: Long? = null + + /** + * 탭 전환·`ON_RESUME` 등 사용자가 요청하지 않은 자동 갱신. + * + * **데이터가 바뀌었을 때만 다시 부른다** (#736). 종전에는 돌아오기만 하면 무조건 + * 재조회해, 화면 off/on 이나 진입 직후의 `ON_RESUME` 까지 같은 요청을 한 번 더 + * 내보냈다 — 마음의 기록 첫 진입 한 번에 요청이 7건 나간 원인 중 하나다. + * + * 진행 중인 로드는 종전대로 Job 으로 막는다. 컴포지션 쪽 플래그가 아니라 VM 이 + * 들고 있는 값으로 판단해야 프로세스 사망 후 복원에서도 중복이 나지 않는다. */ fun refreshOnReturn() { - // 진입 직후의 ON_RESUME 은 init 로드와 겹친다 — 진행 중이면 건너뛴다. - // 컴포지션 쪽 플래그가 아니라 VM 이 들고 있는 Job 으로 판단해야 - // 프로세스 사망 후 복원(VM 재생성 + 플래그 복원)에서도 중복이 나지 않는다. if (loadJob?.isActive == true) return + if (loadedVersion != null && loadedVersion == changeTracker.version) return load(showsLoading = false, keepsStateOnFailure = true) } @@ -89,7 +101,17 @@ class DailyQuestionListViewModel internalState.update { it.copy(loadPhase = LoadPhase.Loading) } } - val today = repository.getToday().getOrNull() + // **조회를 시작하기 직전**의 버전을 잡아 둔다. + // + // 끝난 시점에 읽으면 조회와 겹친 쓰기를 통째로 삼킨다 — GET 이 서버 + // snapshot 을 읽은 뒤 응답이 오는 사이에 create 가 성공하면, 이 결과에는 + // 새 항목이 없는데도 증가한 최신 버전을 «내가 본 버전» 으로 기록한다. + // 그러면 복귀 시 두 값이 같아 재조회를 건너뛰고, 방금 저장한 항목이 + // 목록에서 빠진 채 고정된다 (#736 리뷰). + val versionAtLoadStart = changeTracker.version + + val todayResult = repository.getToday() + val today = todayResult.getOrNull() val listResult = repository.getList() // 새 로드가 이 Job 을 취소했다면 상태는 그쪽이 결정하므로 여기서 멈춘다. // repository 는 `runCatchingCancellable` 로 취소를 다시 던지므로 대개 여기 오기 전에 @@ -112,6 +134,17 @@ class DailyQuestionListViewModel } } } else { + // **today 실패는 «본 적 있다» 로 기록하지 않는다** (#736 리뷰). + // + // today 는 실패해도 화면을 막지 않는다 — 배너만 빠지고 답변 목록은 그대로 + // 쓸 수 있다. 그러나 그때 버전까지 찍어 두면 복귀할 때마다 «본 버전과 같다» 로 + // 재조회를 건너뛰어, **배너가 사라진 채 VM 수명 내내 고정된다.** 서버가 + // 회복돼도 돌아오지 않는다. + // + // 그래서 today 가 실패한 로드는 미완으로 두어 다음 복귀가 다시 부르게 한다. + // #736 이 줄이려는 것은 «달라진 게 없는데 또 부르는» 요청이지, 아직 못 받아 + // 온 것을 다시 받아 오는 요청이 아니다. + loadedVersion = versionAtLoadStart.takeIf { todayResult.isSuccess } internalState.update { // 목록을 새로 받아 왔으면 옛 삭제 실패 안내도 걷는다. 남겨 두면 // «새로 받아 왔는데 실패 안내는 그대로» 가 되어 #716 이 고치려는 diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt index 80d3acf538..b71c2e4d33 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt @@ -55,11 +55,11 @@ data class DailyQuestionWriteUiState( val canSubmit: Boolean get() = // `isNotBlank()` 가 아니라 [isHtmlBlank] 다. 에디터는 아무것도 입력하지 않아도 - // `

` 를 내보내므로 문자열 공백 판정으로는 «화면이 비었는지» 를 알 수 없다 — + // `

`·`
` 를 내보내므로 문자열 공백 판정으로는 «화면이 비었는지» 를 알 수 없다 — // 그 값으로 저장이 열리면 빈 답변이 새로 생기거나, 이어쓰던 draft 본문이 빈 - // HTML 로 PATCH 돼 덮인다 (#1018 리뷰). 같은 판정을 - // [DailyQuestionWriteViewModel.submit] 도 쓴다 — 하단 툴바 임시저장이 - // `enabled` 없는 clickable 이라 이 조건을 우회하기 때문이다. + // HTML 로 PATCH 돼 덮인다. 실제로 빈 답변이 저장되고 작성 화면이 pop 됐다 + // (#722 · #1018 리뷰). 같은 판정을 [DailyQuestionWriteViewModel.submit] 도 쓴다 — + // 하단 툴바 임시저장이 `enabled` 없는 clickable 이라 이 조건을 우회하기 때문이다. !answer.isHtmlBlank() && // 수정·이어쓰기 모드는 오늘 질문을 부르지 않아 questionId 가 없다 — 대상 레코드가 // 있으면 PATCH 로 나가므로 질문 조회를 기다릴 이유가 없다 (#582·#770). diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt index a0281a7d5d..bf60eb3420 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt @@ -265,7 +265,8 @@ class DailyQuestionWriteViewModel } // 태그만 남은 `

` 도 «비었다» 로 본다 — `isBlank()` 는 false 라 여기를 통과해 - // 빈 답변이 create 되거나 이어쓰던 draft 가 빈 본문으로 update 됐다 (#1018 리뷰). + // 빈 답변이 create 되거나 이어쓰던 draft 가 빈 본문으로 update 됐다. 저장 요청뿐 + // 아니라 화면 이탈도 함께 막는다 (#722 · #1018 리뷰). if (state.answer.isHtmlBlank()) { failSubmit(R.string.mindrecord_error_daily_question_answer_required) return diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt index c87826e34c..3e3448c59d 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.DiaryList import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.mapper.toUi import dagger.hilt.android.lifecycle.HiltViewModel @@ -25,6 +26,7 @@ class DiaryListViewModel @Inject constructor( private val repository: DiaryRepository, + private val changeTracker: MindRecordChangeTracker, ) : ViewModel() { private val internalState = MutableStateFlow(InternalState()) private var loadJob: Job? = null @@ -50,20 +52,19 @@ class DiaryListViewModel load(yearMonth) } + /** 마지막으로 성공한 조회 시점의 데이터 버전. 아직 성공한 적이 없으면 null. */ + private var loadedVersion: Long? = null + /** - * 탭 전환·작성 화면 복귀 등 사용자가 요청하지 않은 자동 갱신. + * 탭 전환·`ON_RESUME` 등 사용자가 요청하지 않은 자동 갱신. * - * [selectYearMonth] 와 세 가지가 다르다. - * - 로딩을 방출하지 않는다. 화면이 살아 있는 채로 발화하므로 콘텐츠가 통째 교체되면 - * 캘린더 월과 스크롤 위치가 함께 폐기된다. - * - 실패해도 보고 있던 화면을 유지한다. - * - 보고 있던 월을 그대로 다시 조회한다. 6월을 보는 중에 7월 데이터를 받으면 안 된다. + * **데이터가 바뀌었을 때만 다시 부른다** (#736). 진행 중인 로드는 종전대로 Job 으로 + * 막는다 — 컴포지션 쪽 플래그가 아니라 VM 이 들고 있는 값으로 판단해야 프로세스 + * 사망 후 복원에서도 중복이 나지 않는다. */ fun refreshOnReturn() { - // 진입 직후의 ON_RESUME 은 init 로드와 겹친다 — 진행 중이면 건너뛴다. - // 컴포지션 쪽 플래그가 아니라 VM 이 들고 있는 Job 으로 판단해야 - // 프로세스 사망 후 복원(VM 재생성 + 플래그 복원)에서도 중복이 나지 않는다. if (loadJob?.isActive == true) return + if (loadedVersion != null && loadedVersion == changeTracker.version) return load( yearMonth = internalState.value.yearMonth, showsLoading = false, @@ -109,6 +110,15 @@ class DiaryListViewModel loadPhase = if (showsLoading) LoadPhase.Loading else it.loadPhase, ) } + // **조회를 시작하기 직전**의 버전을 잡아 둔다. + // + // 끝난 시점에 읽으면 조회와 겹친 쓰기를 통째로 삼킨다 — GET 이 서버 + // snapshot 을 읽은 뒤 응답이 오는 사이에 create 가 성공하면, 이 결과에는 + // 새 항목이 없는데도 증가한 최신 버전을 «내가 본 버전» 으로 기록한다. + // 그러면 복귀 시 두 값이 같아 재조회를 건너뛰고, 방금 저장한 항목이 + // 목록에서 빠진 채 고정된다 (#736 리뷰). + val versionAtLoadStart = changeTracker.version + val listResult = repository.getList(yearMonth = yearMonth.toString(), draftOnly = null) // 새 로드가 이 Job 을 취소했다면 상태는 그쪽이 결정하므로 여기서 멈춘다. // repository 는 `runCatchingCancellable` 로 취소를 다시 던지므로 대개 여기 오기 전에 @@ -116,6 +126,7 @@ class DiaryListViewModel ensureActive() listResult .onSuccess { result -> + loadedVersion = versionAtLoadStart internalState.update { // 목록을 새로 받아 왔으면 옛 삭제 실패 안내도 걷는다. 남겨 두면 // «새로 받아 왔는데 실패 안내는 그대로» 가 되어 #716 이 고치려는 diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt index f59041d0b6..8feb47c7ff 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt @@ -3,6 +3,8 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import com.afternote.core.model.user.Receiver import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.util.isHtmlBlank import java.time.LocalDate data class DiaryWriteUiState( @@ -48,18 +50,57 @@ data class DiaryWriteUiState( /** 툴바 "임시저장 N" 표시값. `null` 은 아직 모름(조회 중·실패) (#769). */ val draftCount: Int? = null, ) { + /** 정식 등록 조건 — 제목·본문·기분이 모두 있어야 한다. */ val canSubmit: Boolean + get() = missingForSubmit() == null && isReady + + /** + * 임시저장 조건. + * + * «미완성 보존» 이 목적이라 제목·본문 중 하나만 있어도 저장하려 했지만, **서버가 + * 임시저장에도 제목·본문·기분을 모두 요구한다** — 실서버 실측(2026-08-24): + * + * ``` + * POST /diary {"title":"제목만","content":"","isDraft":true,…} → 400 "내용은 필수입니다." + * POST /diary {"title":"","content":"

본문만

",…} → 400 "제목은 필수입니다." + * POST /diary {"title":"제목","content":"

본문

"} → 400 "오늘의 기분은 필수입니다." + * ``` + * + * 보내면 400 이 되는 조건을 «저장 가능» 으로 표시하면 버튼이 고장 난 것과 같아진다. + * 서버가 `isDraft=true` 에서 검증을 완화해 주기 전까지는 같은 조건을 요구한다 (#1065). + * + * 정식 등록과 갈리는 지점은 남는다 — 실패 사유를 각각 다른 문구로 알린다. + */ + val canSaveDraft: Boolean + get() = missingForDraft() == null && isReady + + /** 임시저장을 막는 첫 번째 누락 항목 (없으면 null). 현재는 정식 등록과 같은 세 가지다. */ + fun missingForDraft(): Int? = missingForSubmit() + + private val isReady: Boolean get() = - title.isNotBlank() && - content.isNotBlank() && - mood != null && - submitState != SubmitState.InProgress && + submitState != SubmitState.InProgress && !isDraftLoading && + // 업로드 중 저장하면 본문에 아직 안 들어간 이미지가 빠진 채 나간다 (#716). !isUploadingImage && // 프리필이 실패했는데 저장하면, 보지 못한 기존 draft 내용을 빈 폼으로 PATCH 해 // 덮어쓴다. 이어쓰기 진입에서 프리필이 실패한 동안은 저장을 막는다 (#716). !(isEditingDraft && draftLoadError != null) + /** + * 등록을 막는 첫 번째 누락 항목 (없으면 null). + * + * 회색 비활성 버튼만으로는 무엇이 빠졌는지 알 수 없어 고장과 구분되지 않았다 (#722). + * 본문은 직렬화된 HTML 이라 `isNotBlank()` 로는 빈 문단을 잡지 못한다. + */ + fun missingForSubmit(): Int? = + when { + title.isBlank() -> R.string.mindrecord_write_diary_missing_title + content.isHtmlBlank() -> R.string.mindrecord_write_diary_missing_content + mood == null -> R.string.mindrecord_write_diary_missing_mood + else -> null + } + val selectedReceivers: List get() = receivers.filter { it.receiverId in selectedReceiverIds } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt index f478a8b761..d121af3492 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt @@ -1,5 +1,6 @@ package com.afternote.feature.mindrecord.presentation.viewmodel +import androidx.annotation.StringRes import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -143,7 +144,26 @@ class DiaryWriteViewModel fun submit(isDraft: Boolean = false) { val state = _uiState.value - if (!state.canSubmit) return + // 임시저장과 정식 등록의 조건을 가른다. 무엇이 막고 있는지도 알린다 — + // 종전에는 사유 없이 return 해 버튼이 고장 난 것처럼 보였다 (#722). + if (isDraft) { + val missing = state.missingForDraft() + if (missing != null) { + failSubmit(missing) + return + } + if (!state.canSaveDraft) return + } else { + val missing = state.missingForSubmit() + if (missing != null) { + failSubmit(missing) + return + } + if (!state.canSubmit) return + } + // 고르지 않은 기분을 지어내지 않는다. 지어내면 그것이 사용자 데이터가 되고 + // (이어쓰기로 열면 «그냥그래» 가 이미 선택돼 보인다) 주간리포트 집계와 감정 + // 분석 입력에도 그대로 들어간다. 위 가드가 미선택을 이미 막는다. val mood = state.mood ?: return viewModelScope.launch { @@ -202,6 +222,15 @@ class DiaryWriteViewModel _uiState.update { it.copy(submitState = SubmitState.Idle) } } + // 실패해도 수신자 행이 "수신자 설정하기" 로 남을 뿐 작성 자체는 가능 — 에러는 조용히 무시. + private fun failSubmit( + @StringRes messageRes: Int, + ) { + _uiState.update { + it.copy(submitState = SubmitState.Failed(UiText.Resource(messageRes))) + } + } + /** * 수신인 목록 조회. **실패를 빈 목록으로 흡수하지 않는다** (#1019). * diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailUiState.kt new file mode 100644 index 0000000000..8b1f8f31c2 --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailUiState.kt @@ -0,0 +1,48 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.presentation.util.RecordContentBlock +import java.time.LocalDate + +/** + * 기록 상세 화면 상태 (#759, Figma 3814:18721 외 3종). + * + * 데일리질문과 일기가 **같은 화면**을 쓴다. 시안 4종의 차이는 두 축뿐이다 — + * 첨부 이미지 유무([heroImageUrl])와 기록 종류([mood] 유무). + */ +sealed interface RecordDetailUiState { + data object Loading : RecordDetailUiState + + data class Success( + val title: String, + /** + * 기록 날짜. **없으면 null 이고 그 줄을 그리지 않는다** — 지어낸 날짜를 그리면 + * 사용자는 그 기록이 실제로 그 날 쓰인 것으로 읽는다. 상세에서 날짜는 부수 + * 정보가 아니라 기록을 식별하는 값이다 (#759 리뷰). + */ + val date: LocalDate?, + /** "수신인 OOO" 로 보여줄 이름들. 지정하지 않았으면 빈 목록이라 줄 자체를 그리지 않는다. */ + val receiverNames: List, + /** + * 헤더 배경에 깔 이미지. null 이면 시안의 "이미지 X" 변형 — + * 사진 대신 옅은 그라데이션을 깔고 글자색이 밝은색에서 어두운색으로 뒤집힌다. + */ + val heroImageUrl: String?, + val blocks: List, + /** 일기에만 있는 오늘의 기분 이모지. 데일리질문이면 null 이라 그 줄이 없다. */ + val mood: String?, + ) : RecordDetailUiState + + data class Error( + val message: UiText, + ) : RecordDetailUiState + + /** + * 목록에 그 기록이 없다 — 지워졌거나 다른 달에 있다. + * + * 통신 실패와 구분한다. 사용자가 할 일이 다르기 때문이다 — 통신 실패는 «다시 시도», + * 없는 기록은 «목록으로». 종전에는 둘이 같은 화면으로 수렴해 재시도를 아무리 눌러도 + * 같은 결과였다 (#759 리뷰). + */ + data object NotFound : RecordDetailUiState +} diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailViewModel.kt new file mode 100644 index 0000000000..e142f56d83 --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailViewModel.kt @@ -0,0 +1,111 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.mapper.toEmoji +import com.afternote.feature.mindrecord.presentation.mapper.toUi +import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute +import com.afternote.feature.mindrecord.presentation.util.RecordContentBlock +import com.afternote.feature.mindrecord.presentation.util.toRecordContentBlocks +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.time.YearMonth +import javax.inject.Inject + +/** + * 기록 상세 화면 ViewModel (#759). + * + * 목록이 이미 본문을 갖고 있지만, 화면을 옮기면 그 상태가 따라오지 않으므로 같은 목록 + * API 로 다시 불러 대상 하나를 고른다 — 작성 화면의 프리필과 같은 방식이다. + * + * **단건 조회 API 가 없어서** 목록 전체를 받는다. 요청 수를 줄이는 방향(#736)과 반대로 + * 보이지만 대안이 없다 — `GET /diary`·`GET /daily-questions` 만 있고 `/{id}` 가 없다. + * 서버에 단건 조회가 생기면 이 왕복이 사라진다. + */ +@HiltViewModel +class RecordDetailViewModel + @Inject + constructor( + savedStateHandle: SavedStateHandle, + private val diaryRepository: DiaryRepository, + private val dailyQuestionRepository: DailyQuestionRepository, + ) : ViewModel() { + private val route = savedStateHandle.toRoute() + + private val _uiState = MutableStateFlow(RecordDetailUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + if (route.isDiary) loadDiary() else loadDailyQuestion() + } + + private fun loadDiary() { + viewModelScope.launch { + diaryRepository + .getList(yearMonth = route.yearMonth ?: YearMonth.now().toString(), draftOnly = null) + .map { list -> list.diaries.firstOrNull { it.diaryId == route.recordId } } + .onSuccess { diary -> + if (diary == null) { + _uiState.value = RecordDetailUiState.NotFound + return@onSuccess + } + val ui = diary.toUi() + val blocks = diary.content.toRecordContentBlocks() + _uiState.value = + RecordDetailUiState.Success( + title = diary.title, + date = ui?.date, + receiverNames = diary.receiverNames, + heroImageUrl = blocks.firstImageUrl(), + blocks = blocks, + mood = diary.todayMood?.toEmoji(), + ) + }.onFailure { failWithLoadError() } + } + } + + private fun loadDailyQuestion() { + viewModelScope.launch { + dailyQuestionRepository + .getList() + .map { list -> list.firstOrNull { it.dailyQuestionId == route.recordId } } + .onSuccess { answer -> + if (answer == null) { + _uiState.value = RecordDetailUiState.NotFound + return@onSuccess + } + // 날짜를 해석하지 못하면 toUi 가 null 을 준다 — 오늘로 메우지 않고 + // 날짜 줄만 비운다 (#751). 본문·수신인은 그대로 보여 준다. + val ui = answer.toUi() + val blocks = answer.content.toRecordContentBlocks() + _uiState.value = + RecordDetailUiState.Success( + title = answer.title, + date = ui?.date, + receiverNames = answer.receiverNames, + heroImageUrl = blocks.firstImageUrl(), + blocks = blocks, + // 데일리질문에는 오늘의 기분이 없다 — 시안에도 그 줄이 없다. + mood = null, + ) + }.onFailure { failWithLoadError() } + } + } + + private fun failWithLoadError() { + _uiState.value = + RecordDetailUiState.Error(UiText.Resource(R.string.mindrecord_detail_load_failed)) + } + } + +/** 헤더 배경에 깔 이미지 — 본문 첫 이미지를 쓴다. 없으면 시안의 "이미지 X" 변형이다 (#759). */ +internal fun List.firstImageUrl(): String? = filterIsInstance().firstOrNull()?.url 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 97e2bcf495..d6549132af 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 @@ -13,6 +13,7 @@ import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion import com.afternote.feature.mindrecord.domain.model.WeeklyReportDay import com.afternote.feature.mindrecord.domain.model.WeeklyReportEmotion import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.model.DailyQuestion import com.afternote.feature.mindrecord.presentation.model.DayBackground @@ -45,11 +46,18 @@ class WeeklyReportViewModel constructor( private val repository: WeeklyReportRepository, private val userRepository: UserRepository, + private val changeTracker: MindRecordChangeTracker, ) : ViewModel() { private val weekOptions: List = buildWeekOptions(today = LocalDate.now()) private val internalState = MutableStateFlow(InternalState()) + + /** 마지막으로 성공한 조회 시점의 데이터 버전 (#736). */ + private var loadedVersion: Long? = null + + /** 이 ViewModel 이 ON_RESUME 을 한 번이라도 받았는지 — init 로드와의 중복을 가른다. */ + private var hasSeenFirstResume: Boolean = false private var loadJob: Job? = null val uiState: StateFlow = @@ -93,11 +101,15 @@ class WeeklyReportViewModel * 소관이다. 보고 있던 주를 그대로 다시 불러 상태를 갱신한다 (#725). */ fun retryEmotionAnalysis() { - // 재조회 정책(로딩·실패 유지·중복 가드)이 한 곳에만 있도록 위임한다. - // 다만 이쪽은 **사용자가 누른** 갱신이라 진행 표시를 낸다 — BE#117 전까지는 - // 재조회가 같은 FAILED 를 돌려주는 것이 기본 경로이고, 그러면 데이터가 같아 - // StateFlow 가 방출조차 하지 않아 눌러도 픽셀이 안 바뀐다. - refreshOnReturn(showsLoading = true) + // refreshOnReturn 에 위임하지 않는다 — 그쪽의 «데이터가 안 바뀌었으면 안 부른다» + // 가드(#736)에 걸려 조회가 아예 안 나간다. 분석 실패는 조회 자체는 성공한 + // 상태(Loaded)라 changeTracker 버전이 그대로이기 때문이다. + // + // 이쪽은 **사용자가 누른** 갱신이라 진행 표시를 낸다 — BE#117 전까지는 재조회가 + // 같은 FAILED 를 돌려주는 것이 기본 경로이고, 그러면 데이터가 같아 StateFlow 가 + // 방출조차 하지 않아 눌러도 픽셀이 안 바뀐다. + if (loadJob?.isActive == true) return + load(targetMonday(), showsLoading = true, keepsStateOnFailure = true) } /** @@ -107,19 +119,48 @@ class WeeklyReportViewModel * 화면을 유지한다. 보고 있던 주를 그대로 다시 조회한다. */ fun refreshOnReturn(showsLoading: Boolean = false) { - // 진입 직후의 ON_RESUME 은 init 로드와 겹친다 — 진행 중이면 건너뛴다. + // **이 ViewModel 이 받는 첫 ON_RESUME 은 init 로드가 이미 덮는다.** + // + // 이 상태를 화면의 rememberSaveable 로 두면 프로세스 사망 뒤 되살아난 «지나갔음» + // 플래그가 새 ViewModel 의 init 과 겹친다 — 저장 상태는 복원되는데 ViewModel 은 + // 새로 만들어지기 때문이다. 그러면 이 PR 이 막으려는 같은 주차 GET 이 다시 두 번 + // 나간다. 그래서 ViewModel 수명에 묶는다 (#736 리뷰). + // + // Job 가드만으로는 부족하다 — 즉시 끝나는 응답(캐시·즉시 실패)에서는 init 로드가 + // 이미 끝나 있어 그냥 통과한다. + if (!hasSeenFirstResume) { + hasSeenFirstResume = true + return + } if (loadJob?.isActive == true) return - // 실패 상태면 **실패한 주**를 다시 시도한다. 이번 주로 되돌아가면 사용자가 - // 보려던 주차가 유실돼, 나갔다 들어와도 복구되지 않는다 (#723). - val current = - when (val phase = internalState.value.loadPhase) { - is LoadPhase.Loaded -> phase.monday - is LoadPhase.Failed -> phase.monday - LoadPhase.Loading -> weekOptions.first().monday - } - load(current, showsLoading = showsLoading, keepsStateOnFailure = true) + // 성공해서 보고 있는 화면이라면, 데이터가 바뀌었을 때만 다시 부른다 (#736). + // 실패 상태는 이 가드에 걸리지 않는다 — 실패한 주차를 다시 시도해야 한다 (#723). + // + // **분석 대기도 통과시킨다.** changeTracker 는 일기·데일리질문의 쓰기 성공에서만 + // 올라가는데, 감정 분석 완료는 서버가 비동기로 채우는 상태라 그 카운터가 모른다. + // 폴링(8회 × 8초 ≈ 1분)이 소진된 뒤 대기가 남아 있으면 복귀 갱신이 유일한 복구 + // 경로인데, 그것까지 막으면 «분석 중» 이 앱 재시작까지 화면에 굳는다 — PENDING + // 에는 재시도 버튼도 없다(카드는 FAILED 전용). + val phase = internalState.value.loadPhase + val awaitsAnalysis = + // emotionAnalysis 가 null 이면 서버가 상태를 안 준 것(#725 UNKNOWN 경로)이라 + // «분석 대기» 로 치지 않는다 — 기다릴 근거가 없다. + phase is LoadPhase.Loaded && phase.report.emotionAnalysis?.status == EmotionAnalysisStatus.PENDING + if (phase is LoadPhase.Loaded && !awaitsAnalysis && loadedVersion == changeTracker.version) return + load(targetMonday(), showsLoading = showsLoading, keepsStateOnFailure = true) } + /** + * 다시 부를 주. 실패 상태면 **실패한 주**를 그대로 다시 시도한다 — 이번 주로 + * 되돌아가면 사용자가 보려던 주차가 유실돼 나갔다 들어와도 복구되지 않는다 (#723). + */ + private fun targetMonday(): LocalDate = + when (val phase = internalState.value.loadPhase) { + is LoadPhase.Loaded -> phase.monday + is LoadPhase.Failed -> phase.monday + LoadPhase.Loading -> weekOptions.first().monday + } + private fun load( monday: LocalDate, showsLoading: Boolean = true, @@ -130,6 +171,13 @@ class WeeklyReportViewModel viewModelScope.launch { // 실패했을 때 되돌아갈 화면을 **로딩으로 덮기 전에** 잡아 둔다 (#723). val previousLoaded = internalState.value.loadPhase.lastLoadedOrNull() + // **조회를 시작하기 직전**의 버전을 잡아 둔다. + // + // 끝난 시점에 읽으면 조회와 겹친 쓰기를 통째로 삼킨다 — GET 이 서버 + // snapshot 을 읽은 뒤 응답이 오는 사이에 쓰기가 성공하면, 이 결과에는 + // 그 변경이 없는데도 증가한 최신 버전을 «내가 본 버전» 으로 기록한다. + // 그러면 복귀 시 두 값이 같아 재조회를 건너뛴다 (#736 리뷰). + val versionAtLoadStart = changeTracker.version if (showsLoading) { internalState.update { it.copy(loadPhase = LoadPhase.Loading) } } @@ -152,6 +200,7 @@ class WeeklyReportViewModel ensureActive() result .onSuccess { (report, profile) -> + loadedVersion = versionAtLoadStart internalState.update { it.copy(loadPhase = LoadPhase.Loaded(monday, report, profile.name)) } diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index 3961b5779b..95a31fe5e8 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -213,10 +213,24 @@ 임시저장한 내용을 불러오는 중이에요… 이미지를 올리는 중이에요. 잠시 뒤 저장해 주세요. 삭제하지 못했어요. 다시 시도해 주세요. + + http:// 또는 https:// 로 시작하는 주소를 입력해 주세요. + + 제목을 입력해 주세요. + 본문을 입력해 주세요. + 오늘의 기분을 골라 주세요. + 임시저장할 내용이 없어요. 아직 열람할 수 있는 기록이 없어요.\n작성자가 전달 시점을 정하면 이곳에서 볼 수 있어요. 마음의 기록을 불러오지 못했어요. 마음의 기록 + + 수신인 + 오늘의 기분 + 기록을 찾을 수 없어요. 목록에서 다시 열어 주세요. + 기록을 불러오지 못했어요. + 데일리 질문 + 일기 – diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt index 2290395309..3bd5ae81ec 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt @@ -25,6 +25,7 @@ import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion import com.afternote.feature.mindrecord.domain.model.WeeklyReportDay +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.domain.testing.FakeMindRecordReceiverRepository @@ -83,11 +84,11 @@ class MindRecordLifecycleTest { DiaryList(listOf(previousPublished), 1, TodayMood.SOSO), ) val repository = scriptedDiaryRepository(diaryLists) - val viewModel = DiaryListViewModel(repository) + val viewModel = DiaryListViewModel(repository, MindRecordChangeTracker()) composeRule.setContent { AfternoteTheme { - DiaryScreen(viewModel = viewModel) + DiaryScreen(viewModel = viewModel, onItemClick = { _, _ -> }) } } @@ -211,7 +212,7 @@ class MindRecordLifecycleTest { val repository = FakeWeeklyReportRepository() repository.results.addLast(Result.failure(IllegalStateException("weekly offline"))) val userRepository = privateProfileRepository("테스트 사용자") - val viewModel = WeeklyReportViewModel(repository, userRepository) + val viewModel = WeeklyReportViewModel(repository, userRepository, MindRecordChangeTracker()) composeRule.setContent { AfternoteTheme { diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/CalendarSelectionTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/CalendarSelectionTest.kt new file mode 100644 index 0000000000..4781882d04 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/CalendarSelectionTest.kt @@ -0,0 +1,93 @@ +package com.afternote.feature.mindrecord.presentation.screen.sender + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertHasNoClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.presentation.component.DailyCalendar +import com.afternote.feature.mindrecord.presentation.model.MindRecordCategoryUi +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 캘린더 날짜 셀 상호작용 가드 (#724). + * + * 공용 `DailyCalendar` 에 `selectedDay`·`onDayClick` 이 아예 없어, 날짜를 눌러도 선택 + * 표시도 기록 필터도 일어나지 않았다. 월 이동만 되고 날짜는 죽어 있던 상태다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CalendarSelectionTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `날짜 셀을 누르면 그 일자가 콜백으로 온다`() { + var clicked: Int? = null + composeRule.setContent { + AfternoteTheme { + DailyCalendar( + year = 2026, + month = 8, + type = MindRecordCategoryUi.Diary, + onPrevMonth = {}, + onNextMonth = {}, + answeredDays = setOf(12), + onDayClick = { clicked = it }, + ) + } + } + + composeRule.onAllNodesWithText("12")[0].assertHasClickAction() + composeRule.onAllNodesWithText("12")[0].performClick() + + assertEquals(12, clicked) + } + + @Test + fun `콜백을 주지 않으면 셀은 클릭 대상이 아니다`() { + // 수신자 화면 등 선택이 없는 곳에서 헛클릭이 생기지 않게 한다. + composeRule.setContent { + AfternoteTheme { + DailyCalendar( + year = 2026, + month = 8, + type = MindRecordCategoryUi.Diary, + onPrevMonth = {}, + onNextMonth = {}, + ) + } + } + + composeRule.onAllNodesWithText("12")[0].assertHasNoClickAction() + } + + @Test + fun `월 이동 수단은 기록이 없는 달에도 남는다`() { + // 종전에는 빈 상태가 캘린더를 통째로 대체해 이전·다음 월 버튼까지 사라졌다. + composeRule.setContent { + AfternoteTheme { + DailyCalendar( + year = 2026, + month = 8, + type = MindRecordCategoryUi.Diary, + onPrevMonth = {}, + onNextMonth = {}, + answeredDays = emptySet(), + ) + } + } + + composeRule.onNodeWithText("2026년 8월").assertIsDisplayed() + composeRule.onNodeWithText("0개의 답변 완료").assertIsDisplayed() + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/RecordListMonthTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/RecordListMonthTest.kt new file mode 100644 index 0000000000..4649f0a39b --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/RecordListMonthTest.kt @@ -0,0 +1,59 @@ +package com.afternote.feature.mindrecord.presentation.screen.sender + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.presentation.model.DailyDiary +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.YearMonth + +/** + * 목록이 **보고 있는 달**을 상세로 넘기는지 (#759 리뷰). + * + * 종전에는 내비게이션이 `YearMonth.now()` 를 넣어, 지난달 기록을 탭하면 상세가 이번 달 + * 목록에서 그 기록을 찾다 실패했다 — 이번 달 기록만 열렸다. 목록은 달을 바꿀 수 있는 + * 화면이라 흔히 밟히는 경로다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RecordListMonthTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `카드를 누르면 보고 있는 달이 함께 전달된다`() { + val lastMonth = YearMonth.now().minusMonths(1) + var clicked: Pair? = null + + composeRule.setContent { + AfternoteTheme { + DiaryListContent( + isListView = true, + yearMonth = lastMonth, + onYearMonthChanged = {}, + diaries = + listOf( + DailyDiary( + id = 11L, + title = "지난달 기록", + date = lastMonth.atDay(3), + content = "본문", + ), + ), + onItemClick = { id, month -> clicked = id to month }, + ) + } + } + + composeRule.onAllNodesWithText("지난달 기록")[0].performClick() + + assertEquals(11L to lastMonth, clicked) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/RecordContentBlockTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/RecordContentBlockTest.kt new file mode 100644 index 0000000000..5a2199e2b0 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/RecordContentBlockTest.kt @@ -0,0 +1,78 @@ +package com.afternote.feature.mindrecord.presentation.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 상세 화면 본문 블록 분할 가드 (#759). + * + * 시안(3814:18721)은 본문을 «문단 → 이미지 → 문단» 처럼 섞어 보여준다. 본문은 HTML + * 조각이고 이미지는 그 안의 `img` 태그이므로, 태그를 경계로 잘라 순서를 지켜야 한다. + * + * `htmlToPlainText` 가 `HtmlCompat` 을 타서 Robolectric 이 필요하다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RecordContentBlockTest { + @Test + fun `문단과 이미지가 본문 순서대로 나온다`() { + val html = "

앞 문단

뒤 문단

" + + val blocks = html.toRecordContentBlocks() + + assertEquals(3, blocks.size) + assertEquals("앞 문단", (blocks[0] as RecordContentBlock.Text).text) + assertEquals("https://cdn/a.png", (blocks[1] as RecordContentBlock.Image).url) + assertEquals("뒤 문단", (blocks[2] as RecordContentBlock.Text).text) + } + + @Test + fun `이미지가 없으면 문단만 남는다`() { + // 시안의 "이미지 X" 변형 — 헤더도 이 결과로 갈린다. + val blocks = "

글만 있는 본문

".toRecordContentBlocks() + + assertEquals(1, blocks.size) + assertTrue(blocks.single() is RecordContentBlock.Text) + } + + @Test + fun `이미지 앞뒤의 빈 문단은 빈 줄로 남지 않는다`() { + // 에디터가 이미지를

로 감싸 내보낸다 — 그대로 그리면 빈 줄이 생긴다. + val html = "

본문

" + + val blocks = html.toRecordContentBlocks() + + assertEquals(2, blocks.size) + assertEquals("본문", (blocks[0] as RecordContentBlock.Text).text) + assertEquals("https://cdn/a.png", (blocks[1] as RecordContentBlock.Image).url) + } + + @Test + fun `이미지가 여러 장이면 모두 순서대로 남는다`() { + val html = "

사이

" + + val blocks = html.toRecordContentBlocks() + + assertEquals( + listOf("https://cdn/1.png", "https://cdn/2.png"), + blocks.filterIsInstance().map { it.url }, + ) + assertEquals("사이", (blocks[1] as RecordContentBlock.Text).text) + } + + @Test + fun `src 가 빈 img 는 블록을 만들지 않는다`() { + val blocks = "

본문

".toRecordContentBlocks() + + assertEquals(1, blocks.size) + } + + @Test + fun `빈 본문은 블록이 없다`() { + assertEquals(emptyList(), "

".toRecordContentBlocks()) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/WriteValidationTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/WriteValidationTest.kt new file mode 100644 index 0000000000..cd8a6ac22d --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/WriteValidationTest.kt @@ -0,0 +1,58 @@ +package com.afternote.feature.mindrecord.presentation.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 작성 화면 입력 검증 가드 (#722). + * + * 두 가지가 그대로 통과하고 있었다. + * - 리치 에디터가 빈 문단을 `

`·`
` 로 직렬화해, 화면이 비어 있어도 + * `isNotBlank()` 검증을 통과했다 (빈 답변 저장 후 화면 이탈). + * - 링크 입력이 임의 문자열을 허용해 그대로 `a` 태그의 `href` 가 됐다. + */ +class WriteValidationTest { + @Test + fun `빈 문단은 내용이 없는 것으로 본다`() { + // compose-rich-editor 가 실제로 내보내는 형태들. + listOf("", " ", "

", "
", "


", "

 

").forEach { html -> + assertTrue("비어 있어야 한다: $html", html.isHtmlBlank()) + } + } + + @Test + fun `글자가 하나라도 있으면 내용이 있는 것으로 본다`() { + listOf("

ㄱ

", "

안녕

", "본문").forEach { html -> + assertFalse("내용이 있어야 한다: $html", html.isHtmlBlank()) + } + } + + @Test + fun `http 와 https 주소만 링크로 받는다`() { + assertTrue("https://example.com".isSupportedLinkUrl()) + assertTrue("http://example.com/path?q=1".isSupportedLinkUrl()) + } + + @Test + fun `형식이 아닌 값은 링크로 받지 않는다`() { + // 종전에는 이런 값도 그대로 href 가 됐다. + listOf("", " ", "그냥 텍스트", "example.com", "ftp://example.com", "https://", "https:// example.com") + .forEach { raw -> assertFalse("거부해야 한다: $raw", raw.isSupportedLinkUrl()) } + } + + @Test + fun `자바스크립트 스킴은 거부한다`() { + assertFalse("javascript:alert(1)".isSupportedLinkUrl()) + } + + @Test + fun `속성에 넣기 전에 이스케이프한다`() { + // 검증을 통과해도 따옴표·꺾쇠가 섞이면 a 태그를 깨고 나올 수 있다. + assertEquals( + "https://example.com/?a=1&b=<x>"'", + "https://example.com/?a=1&b=\"'".escapeHtml(), + ) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ChangeTrackerRefreshTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ChangeTrackerRefreshTest.kt new file mode 100644 index 0000000000..0371a9a91f --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ChangeTrackerRefreshTest.kt @@ -0,0 +1,78 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +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.Before +import org.junit.Test + +/** + * 쓰기 뒤 복귀 갱신이 실제로 다시 조회하는지 (#520 · #736 · #966 리뷰). + * + * 재조회 가드는 «데이터가 안 바뀌었으면 안 부른다» 인데, 그 판정은 data 계층이 쓰기 성공에 + * [MindRecordChangeTracker.notifyChanged] 를 부른다는 전제 위에 선다. 그 배선이 빠지면 + * 가드가 «안 바뀌었다» 로 보고 갱신을 건너뛰어 «작성하고 돌아왔는데 목록이 그대로» 가 된다. + * + * androidTest 의 같은 시나리오는 컴파일만 되고 실행되지 않아 이 성질을 지켜 주지 못했다 — + * 그래서 JVM 쪽에도 둔다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChangeTrackerRefreshTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `쓰기가 있었으면 복귀 갱신이 다시 조회한다`() = + runTest(dispatcher) { + val changeTracker = MindRecordChangeTracker() + val repository = FakeDailyQuestionRepository(changeTracker = changeTracker) + val viewModel = DailyQuestionListViewModel(repository, changeTracker) + // uiState 는 WhileSubscribed 라 구독자가 없으면 로드가 시작되지 않는다. + backgroundScope.launch { viewModel.uiState.collect { } } + advanceUntilIdle() + val afterFirstLoad = repository.listQueries.size + + repository.create(DailyQuestionCreatePayload(content = "새 답변", isDraft = false, questionId = 1L)) + viewModel.refreshOnReturn() + advanceUntilIdle() + + assertEquals(afterFirstLoad + 1, repository.listQueries.size) + } + + @Test + fun `쓰기가 없었으면 다시 조회하지 않는다`() = + runTest(dispatcher) { + // 같은 가드의 반대 방향 — 탭을 오가도 데이터가 그대로면 부르지 않는다 (#736). + val changeTracker = MindRecordChangeTracker() + val repository = FakeDailyQuestionRepository(changeTracker = changeTracker) + val viewModel = DailyQuestionListViewModel(repository, changeTracker) + // uiState 는 WhileSubscribed 라 구독자가 없으면 로드가 시작되지 않는다. + backgroundScope.launch { viewModel.uiState.collect { } } + advanceUntilIdle() + val afterFirstLoad = repository.listQueries.size + + repeat(3) { viewModel.refreshOnReturn() } + advanceUntilIdle() + + assertEquals(afterFirstLoad, repository.listQueries.size) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryDraftConditionTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryDraftConditionTest.kt new file mode 100644 index 0000000000..a66a12b495 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryDraftConditionTest.kt @@ -0,0 +1,85 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.presentation.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 일기 등록·임시저장 조건 분리 가드 (#722). + * + * 종전에는 `submit(isDraft = true)` 도 정식 등록과 같은 `canSubmit` 을 써서, 제목·본문· + * 기분이 모두 있어야만 임시저장이 됐다 — **미완성 내용을 보존하는 것이 목적인 기능이 + * 완성된 글에서만 동작**했다. 막힐 때 사유도 없었다. + */ +class DiaryDraftConditionTest { + private fun state( + title: String = "", + content: String = "", + mood: TodayMood? = null, + ) = DiaryWriteUiState(title = title, content = content, mood = mood) + + @Test + fun `제목만으로는 임시저장할 수 없다`() { + // 서버가 임시저장에도 제목·본문·기분을 모두 요구한다 — 실측 400 (#1065). + // 보내면 실패하는 조건을 «저장 가능» 으로 표시하면 버튼이 고장 난 것과 같다. + assertFalse(state(title = "쓰다 만 제목").canSaveDraft) + } + + @Test + fun `본문만으로도 임시저장할 수 없다`() { + assertFalse(state(content = "

쓰다 만 본문

").canSaveDraft) + } + + @Test + fun `셋이 다 있으면 임시저장된다`() { + assertTrue( + state(title = "제목", content = "

본문

", mood = TodayMood.HAPPY).canSaveDraft, + ) + } + + @Test + fun `임시저장도 무엇이 빠졌는지 알려준다`() { + // 조용히 막으면 버튼이 죽은 것과 구분되지 않는다. + assertEquals(R.string.mindrecord_write_diary_missing_title, state().missingForDraft()) + assertEquals( + R.string.mindrecord_write_diary_missing_mood, + state(title = "제목", content = "

본문

").missingForDraft(), + ) + } + + @Test + fun `기분만 골라서는 임시저장할 내용이 없다`() { + assertFalse(state(mood = TodayMood.HAPPY).canSaveDraft) + } + + @Test + fun `빈 문단만 있는 본문은 임시저장 대상이 아니다`() { + // 리치 에디터가 내보내는 빈 HTML 을 내용으로 세면 안 된다. + assertFalse(state(content = "

").canSaveDraft) + } + + @Test + fun `정식 등록은 셋을 모두 요구한다`() { + assertFalse(state(title = "제목", content = "

본문

").canSubmit) + assertTrue(state(title = "제목", content = "

본문

", mood = TodayMood.SOSO).canSubmit) + } + + @Test + fun `무엇이 빠졌는지 순서대로 알려준다`() { + // 회색 비활성 버튼만으로는 고장과 구분되지 않는다. + assertEquals(R.string.mindrecord_write_diary_missing_title, state().missingForSubmit()) + assertEquals( + R.string.mindrecord_write_diary_missing_content, + state(title = "제목").missingForSubmit(), + ) + assertEquals( + R.string.mindrecord_write_diary_missing_mood, + state(title = "제목", content = "

본문

").missingForSubmit(), + ) + assertNull(state(title = "제목", content = "

본문

", mood = TodayMood.SAD).missingForSubmit()) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/LoadRaceVersionTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/LoadRaceVersionTest.kt new file mode 100644 index 0000000000..29af801329 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/LoadRaceVersionTest.kt @@ -0,0 +1,78 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +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.Before +import org.junit.Test + +/** + * 조회와 겹친 쓰기를 재조회 가드가 삼키지 않는지 (#736 리뷰). + * + * 재조회 가드는 «내가 본 버전» 과 현재 버전을 비교한다. 그 «내가 본 버전» 을 조회가 **끝난** + * 시점에 읽으면, GET 이 서버 snapshot 을 읽은 뒤 응답이 오는 사이에 성공한 쓰기까지 «이미 + * 봤다» 로 기록된다. 결과에는 그 항목이 없는데 버전만 최신이라, 복귀해도 재조회가 일어나지 + * 않고 방금 저장한 항목이 목록에서 빠진 채 고정된다. + * + * 창이 열려 있어야 재현되므로 조회를 붙잡아 그 사이에 쓰기를 끼워 넣는다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class LoadRaceVersionTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `조회 도중 들어온 쓰기는 복귀 갱신에서 다시 조회된다`() = + runTest(dispatcher) { + val changeTracker = MindRecordChangeTracker() + val repository = FakeDailyQuestionRepository(changeTracker = changeTracker) + val gate = CompletableDeferred() + var heldOnce = false + repository.onGetList = { _, _ -> + // 첫 조회만 붙잡는다 — 그 창에서 쓰기를 끼워 넣는다. + if (!heldOnce) { + heldOnce = true + gate.await() + } + Result.success(emptyList()) + } + val viewModel = DailyQuestionListViewModel(repository, changeTracker) + backgroundScope.launch { viewModel.uiState.collect { } } + advanceUntilIdle() + + // 조회가 서버 snapshot 을 읽은 뒤, 응답이 오기 전에 쓰기가 성공한다. + repository.create(DailyQuestionCreatePayload(content = "창 안에서 저장", isDraft = false, questionId = 1L)) + gate.complete(Unit) + advanceUntilIdle() + val afterFirstLoad = repository.listQueries.size + + viewModel.refreshOnReturn() + advanceUntilIdle() + + assertEquals( + "조회와 겹친 쓰기를 «이미 봤다» 로 기록해 재조회를 건너뛰었다", + afterFirstLoad + 1, + repository.listQueries.size, + ) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt index 2dc89e1be7..6cc07cf1bf 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt @@ -12,6 +12,7 @@ import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -56,7 +57,7 @@ class MindRecordFailureRecoveryTest { fun `삭제 실패 안내는 다음 삭제가 성공하면 사라진다`() = runTest(dispatcher) { val repository = FlakyDeleteRepository() - val viewModel = DailyQuestionListViewModel(repository = repository) + val viewModel = DailyQuestionListViewModel(repository = repository, changeTracker = MindRecordChangeTracker()) // uiState 는 WhileSubscribed 라 구독자가 없으면 Loading 에 머문다. backgroundScope.launch { viewModel.uiState.collect { } } advanceUntilIdle() @@ -84,7 +85,7 @@ class MindRecordFailureRecoveryTest { // 리뷰가 스크린샷으로 실증한 경로 — 삭제 재시도가 아니라 **재조회 성공**이다. // 기내모드 해제 → 탭 전환 → 목록 정상 재로드인데도 배너가 남아 있었다. val repository = FlakyDeleteRepository() - val viewModel = DailyQuestionListViewModel(repository = repository) + val viewModel = DailyQuestionListViewModel(repository = repository, changeTracker = MindRecordChangeTracker()) backgroundScope.launch { viewModel.uiState.collect { } } advanceUntilIdle() @@ -150,6 +151,73 @@ class MindRecordFailureRecoveryTest { ) uploading.join() } + + @Test + fun `today 만 실패한 로드는 복귀 재조회를 막지 않는다`() = + runTest(dispatcher) { + // today 는 실패해도 화면을 막지 않는다 — 배너만 빠진다. 그래서 실패를 삼키고 + // «본 버전» 까지 찍어 두면, 복귀할 때마다 재조회를 건너뛰어 **서버가 회복돼도 + // 배너가 돌아오지 않는다**. 그 고정 상태를 본다 (#736 리뷰). + val repository = FlakyTodayRepository() + val viewModel = DailyQuestionListViewModel(repository = repository, changeTracker = MindRecordChangeTracker()) + backgroundScope.launch { viewModel.uiState.collect { } } + advanceUntilIdle() + + assertEquals("진입 시 today 1회", 1, repository.todayCalls) + assertNull( + "today 가 실패했으니 배너는 비어 있다", + (viewModel.uiState.value as DailyQuestionListUiState.Success).todayQuestion, + ) + + // 데이터는 그대로다 — 버전이 오르지 않으므로 «달라진 게 없으면 안 부른다» 규칙과 + // 정면으로 부딪히는 조건이다. 그래도 아직 못 받아 온 것은 다시 받아 와야 한다. + repository.succeeds = true + viewModel.refreshOnReturn() + advanceUntilIdle() + + assertEquals("복귀 시 다시 부른다", 2, repository.todayCalls) + assertNotNull( + "서버가 회복되면 배너가 돌아온다", + (viewModel.uiState.value as DailyQuestionListUiState.Success).todayQuestion, + ) + + // 이제는 완전히 성공했으니 #736 의 «달라진 게 없으면 안 부른다» 가 다시 걸린다. + viewModel.refreshOnReturn() + advanceUntilIdle() + assertEquals("성공한 뒤에는 복귀해도 더 부르지 않는다", 2, repository.todayCalls) + } +} + +/** today 만 실패시키는 fake — 목록은 항상 성공한다. */ +private class FlakyTodayRepository : DailyQuestionRepository { + var succeeds = false + var todayCalls = 0 + private set + + override suspend fun getList( + date: String?, + draftOnly: Boolean?, + ): Result> = Result.success(emptyList()) + + override suspend fun getToday(): Result { + todayCalls++ + return if (succeeds) { + Result.success( + TodayDailyQuestion(questionId = 1L, day = 1, content = "질문", isAnswered = false, isDraft = false), + ) + } else { + Result.failure(IllegalStateException("today 실패")) + } + } + + override suspend fun create(payload: DailyQuestionCreatePayload): Result = Result.success(1L) + + override suspend fun update( + id: Long, + payload: DailyQuestionUpdatePayload, + ): Result = Result.success(1L) + + override suspend fun delete(id: Long): Result = Result.success(Unit) } /** 첫 삭제는 실패하고, [succeedsNext] 를 켜면 성공한다. */ diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordRequestCountTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordRequestCountTest.kt new file mode 100644 index 0000000000..b1b400cb0b --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordRequestCountTest.kt @@ -0,0 +1,230 @@ +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.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload +import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis +import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker +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.advanceTimeBy +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.assertTrue +import org.junit.Before +import org.junit.Test +import java.lang.reflect.Proxy + +/** + * 화면별 요청 수 회귀 가드 (#736). + * + * 마음의 기록 첫 진입 한 번에 HTTP 요청이 7건 나갔다 — 화면에 보이는 데일리 질문에 + * 필요한 건 2건이고, 나머지는 **미노출 탭 프리페치**와 **최초 `ON_RESUME` 중복**이었다. + * + * 여기서 고정하는 계약: 자동 갱신은 **데이터가 바뀌었을 때만** 다시 부른다. 갱신 자체를 + * 없애면 #520(작성 후 목록 미갱신)이 되돌아오므로, 그 경계를 함께 고정한다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MindRecordRequestCountTest { + private val dispatcher = StandardTestDispatcher() + private val changeTracker = MindRecordChangeTracker() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `데일리질문 첫 진입은 조회 2건으로 끝난다`() = + runTest(dispatcher) { + // today 1건 + 목록 1건. 최초 ON_RESUME 이 같은 두 건을 다시 내보내면 안 된다. + val repository = CountingDailyQuestionRepository() + val viewModel = startDailyQuestion(repository) + + viewModel.refreshOnReturn() // 진입 직후의 ON_RESUME + advanceUntilIdle() + + assertEquals("today 조회", 1, repository.todayCalls) + assertEquals("목록 조회", 1, repository.listCalls) + } + + @Test + fun `탭을 오가도 데이터가 그대로면 다시 부르지 않는다`() = + runTest(dispatcher) { + val repository = CountingDailyQuestionRepository() + val viewModel = startDailyQuestion(repository) + + repeat(5) { viewModel.refreshOnReturn() } + advanceUntilIdle() + + assertEquals(1, repository.listCalls) + } + + @Test + fun `쓰기가 성공하면 다음 복귀에서 최신 목록을 부른다`() = + runTest(dispatcher) { + // #520 회귀 방지 — 작성하고 돌아왔는데 목록이 그대로면 안 된다. + val repository = CountingDailyQuestionRepository() + val viewModel = startDailyQuestion(repository) + + changeTracker.notifyChanged() // 작성 화면에서 저장 성공 + viewModel.refreshOnReturn() + advanceUntilIdle() + + assertEquals(2, repository.listCalls) + } + + @Test + fun `한 번 갱신한 뒤 또 돌아오면 다시 부르지 않는다`() = + runTest(dispatcher) { + val repository = CountingDailyQuestionRepository() + val viewModel = startDailyQuestion(repository) + + changeTracker.notifyChanged() + viewModel.refreshOnReturn() + advanceUntilIdle() + viewModel.refreshOnReturn() + advanceUntilIdle() + + assertEquals("변경 1회에 갱신 1회", 2, repository.listCalls) + } + + @Test + fun `주간리포트도 데이터가 그대로면 복귀에 다시 부르지 않는다`() = + runTest(dispatcher) { + val repository = CountingWeeklyReportRepository() + val viewModel = WeeklyReportViewModel(repository, userRepository(), changeTracker) + backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } + advanceUntilIdle() + + repeat(3) { viewModel.refreshOnReturn() } + advanceUntilIdle() + + assertEquals("탭을 처음 열 때 1회만", 1, repository.calls) + } + + @Test + fun `분석이 끝나지 않았으면 복귀 갱신을 막지 않는다`() = + runTest(dispatcher) { + // changeTracker 는 일기·데일리질문의 **쓰기** 성공에서만 올라간다. 감정 분석 + // 완료는 서버가 비동기로 채우는 상태라 그 카운터가 모른다. 폴링(8회 × 8초)이 + // 소진된 뒤 대기가 남으면 복귀 갱신이 유일한 복구 경로인데, «데이터가 그대로면 + // 부르지 않는다» 가드가 그것까지 막으면 «분석 중» 이 앱 재시작까지 굳는다. + val repository = CountingWeeklyReportRepository(pendingAnalysis = true) + val viewModel = WeeklyReportViewModel(repository, userRepository(), changeTracker) + backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } + advanceUntilIdle() + advanceTimeBy(10 * 60 * 1000L) + advanceUntilIdle() + // 화면에 들어온 첫 ON_RESUME 은 init 조회가 덮으므로 건너뛴다 — 여기서 보려는 + // 것은 «나갔다 돌아온» 갱신이라 그 뒤부터 센다 (#736 리뷰). + viewModel.refreshOnReturn() + advanceUntilIdle() + val afterPolling = repository.calls + + viewModel.refreshOnReturn() + advanceUntilIdle() + + // 갱신이 나갔는지만 본다 — 새 로드가 아직 PENDING 이면 폴링도 함께 다시 도는 것이 + // 정상이라 «정확히 1건» 으로 조이면 그 정상 동작이 실패가 된다. + assertTrue( + "폴링이 끝나도 복귀 갱신은 통과한다", + repository.calls > afterPolling, + ) + } + + // ── 테스트 도구 ─────────────────────────────────────────────────────────── + + private fun TestScope.startDailyQuestion(repository: DailyQuestionRepository): DailyQuestionListViewModel { + val viewModel = DailyQuestionListViewModel(repository, changeTracker) + backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } + advanceUntilIdle() + return viewModel + } + + private class CountingDailyQuestionRepository : DailyQuestionRepository { + var todayCalls = 0 + var listCalls = 0 + + override suspend fun getList( + date: String?, + draftOnly: Boolean?, + ): Result> { + listCalls++ + return Result.success(emptyList()) + } + + override suspend fun getToday(): Result { + todayCalls++ + return Result.success( + TodayDailyQuestion(questionId = 1L, day = 1, content = "질문", isAnswered = false), + ) + } + + override suspend fun create(payload: DailyQuestionCreatePayload): Result = error("호출되면 안 됨") + + override suspend fun update( + id: Long, + payload: DailyQuestionUpdatePayload, + ): Result = error("호출되면 안 됨") + + override suspend fun delete(id: Long): Result = error("호출되면 안 됨") + } + + private class CountingWeeklyReportRepository( + private val pendingAnalysis: Boolean = false, + ) : WeeklyReportRepository { + var calls = 0 + + override suspend fun getWeeklyReport(date: String): Result { + calls++ + return Result.success( + WeeklyReport( + dailyQuestionAmount = 0, + diaryAmount = 0, + summaryText = "", + week = emptyList(), + dailyQuestions = emptyList(), + emotions = emptyList(), + emotionAnalysis = + if (pendingAnalysis) { + EmotionAnalysis(total = 1, succeeded = 0, pending = 1, failed = 0) + } else { + EmotionAnalysis(total = 0, succeeded = 0, pending = 0, failed = 0) + }, + ), + ) + } + } + + 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 +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailHeroImageTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailHeroImageTest.kt new file mode 100644 index 0000000000..d694d56223 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailHeroImageTest.kt @@ -0,0 +1,63 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import com.afternote.feature.mindrecord.presentation.util.RecordContentBlock +import com.afternote.feature.mindrecord.presentation.util.toRecordContentBlocks +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 상세 화면 헤더 이미지 선택 규칙 (#759). + * + * 시안 4종은 첨부 이미지 유무로만 갈린다. 서버는 "대표 이미지" 를 따로 주지 않으므로 + * 본문 HTML 에서 **첫 이미지**를 뽑아 헤더로 쓰고, 하나도 없으면 그라데이션 변형이 된다. + * + * 본문 분할이 `HtmlCompat` 을 타서 Robolectric 이 필요하다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RecordDetailHeroImageTest { + @Test + fun `본문 첫 이미지를 헤더로 쓴다`() { + val blocks = + ( + "

앞 문단입니다.

" + + "" + + "" + ).toRecordContentBlocks() + + assertEquals("https://cdn.example.com/first.png", blocks.firstImageUrl()) + } + + @Test + fun `이미지가 글 중간에 있어도 헤더로 올린다`() { + // 헤더는 본문 순서와 무관하다 — 첫 문단 뒤에 붙은 사진도 시안의 "이미지 O" 변형이다. + val blocks = + "

앞 문단

뒤 문단

" + .toRecordContentBlocks() + + assertEquals("https://cdn.example.com/mid.png", blocks.firstImageUrl()) + } + + @Test + fun `이미지가 없으면 헤더 이미지도 없다`() { + val blocks = "

글만 있는 본문입니다.

".toRecordContentBlocks() + + assertNull(blocks.firstImageUrl()) + assertEquals(1, blocks.count { it is RecordContentBlock.Text }) + } + + @Test + fun `본문이 비어도 터지지 않는다`() { + assertNull("".toRecordContentBlocks().firstImageUrl()) + } + + @Test + fun `src 가 빈 img 는 헤더로 쓰지 않는다`() { + // 빈 src 를 그대로 넘기면 헤더가 "이미지 O" 변형으로 그려진 뒤 영원히 비어 있다. + assertNull("

본문

".toRecordContentBlocks().firstImageUrl()) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt index 4d4f5af124..469f4c605d 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt @@ -7,6 +7,7 @@ import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportEmotion import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -35,6 +36,7 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class WeeklyReportEmotionAnalysisTest { private val dispatcher = StandardTestDispatcher() + private val changeTracker = MindRecordChangeTracker() @Before fun setUp() { @@ -219,7 +221,7 @@ class WeeklyReportEmotionAnalysisTest { return Result.success(report) } } - return WeeklyReportViewModel(repository, userRepository()) + return WeeklyReportViewModel(repository, userRepository(), changeTracker) } private fun userRepository(): FakeUserRepository = 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 index e396953aff..b6205f4b2f 100644 --- 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 @@ -5,6 +5,7 @@ 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 com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -35,6 +36,7 @@ import java.time.LocalDate @OptIn(ExperimentalCoroutinesApi::class) class WeeklyReportRecoveryTest { private val dispatcher = StandardTestDispatcher() + private val changeTracker = MindRecordChangeTracker() private val thisMonday: LocalDate = LocalDate.now().with(DayOfWeek.MONDAY) private val lastMonday: LocalDate = thisMonday.minusWeeks(1) @@ -86,6 +88,10 @@ class WeeklyReportRecoveryTest { viewModel.selectWeek(lastMonday) advanceUntilIdle() + // 화면에 들어온 첫 ON_RESUME 은 init 조회가 덮으므로 건너뛴다 (#736 리뷰). + viewModel.refreshOnReturn() + advanceUntilIdle() + // 나갔다 돌아옴 — 그 사이 서버가 복구됐다고 본다. repository.script = mapOf(lastMonday to Result.success(report(diaryAmount = 7))) viewModel.refreshOnReturn() @@ -113,6 +119,38 @@ class WeeklyReportRecoveryTest { assertEquals(listOf(lastMonday, lastMonday), repository.requested.takeLast(2)) } + @Test + fun `새 ViewModel 의 첫 복귀 갱신은 init 조회를 중복하지 않는다`() = + runTest(dispatcher) { + // 즉시 실패하는 응답이라 init 조회가 ON_RESUME 전에 이미 끝나 있다 — Job 가드가 + // 막지 못하는 창이다. 첫 resume 판단을 화면 저장 상태에 두면 프로세스 사망 뒤 + // «지나갔음» 만 복원돼 새 ViewModel 의 init 과 겹친다 (#736 리뷰). + val repository = ScriptedRepository(emptyMap()) + val viewModel = start(repository) + assertEquals(1, repository.requested.size) + + // 프로세스 복원 직후의 첫 ON_RESUME 에 해당한다. + viewModel.refreshOnReturn() + advanceUntilIdle() + + assertEquals(1, repository.requested.size) + } + + @Test + fun `두 번째 복귀부터는 실패한 주차를 다시 시도한다`() = + runTest(dispatcher) { + // 첫 resume 을 건너뛰는 것이 «영영 갱신 안 함» 이 되면 #723 이 되돌아온다. + val repository = ScriptedRepository(emptyMap()) + val viewModel = start(repository) + viewModel.refreshOnReturn() + advanceUntilIdle() + + viewModel.refreshOnReturn() + advanceUntilIdle() + + assertEquals(2, repository.requested.size) + } + // ── 테스트 도구 ─────────────────────────────────────────────────────────── private class ScriptedRepository( @@ -139,7 +177,7 @@ class WeeklyReportRecoveryTest { ) private fun TestScope.start(repository: WeeklyReportRepository): WeeklyReportViewModel { - val viewModel = WeeklyReportViewModel(repository, userRepository()) + val viewModel = WeeklyReportViewModel(repository, userRepository(), changeTracker) backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } advanceUntilIdle() return viewModel diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt index 636198025f..a173c39b36 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt @@ -6,6 +6,7 @@ import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.model.MindRecordCategoryUi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -31,6 +32,7 @@ import java.time.LocalDate @OptIn(ExperimentalCoroutinesApi::class) class WeeklyReportWeekRangeTest { private val dispatcher = StandardTestDispatcher() + private val changeTracker = MindRecordChangeTracker() @Before fun setUp() { @@ -123,7 +125,7 @@ class WeeklyReportWeekRangeTest { object : WeeklyReportRepository { override suspend fun getWeeklyReport(date: String): Result = Result.success(report) } - val viewModel = WeeklyReportViewModel(repository, userRepository()) + val viewModel = WeeklyReportViewModel(repository, userRepository(), changeTracker) backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } advanceUntilIdle() return viewModel.uiState.value as WeeklyReportUiState.Success