From 7abc8ccf29cd21bbab053b309e4b312e3b854d6f Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 14:25:09 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(mindrecord):=20=EB=8D=B0=EC=9D=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=A7=88=EB=AC=B8=C2=B7=EC=9D=BC=EA=B8=B0=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=84=20=EB=B6=99=EC=9D=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록에 저장된 기록의 본문을 열어 볼 수단이 없었다 — 상세 라우트가 없고 목록 카드에 클릭 콜백도 없었다. 08-05 시안 4종(3814:18721·18739·18796·18813)에 맞춰 한 화면으로 구현한다. 시안 4종은 두 축의 조합이라 화면을 나누지 않는다 — 첨부 이미지 유무(헤더 배경·글자색이 뒤집힌다)와 기록 종류(일기에만 오늘의 기분 줄). - `RecordDetailRoute(recordId, isDiary, yearMonth)` 와 상세 화면·ViewModel 신설 - 본문 HTML 을 `img` 태그 경계로 잘라 «문단 → 이미지 → 문단» 순서를 지킨다 - 헤더 이미지는 본문 첫 이미지를 쓴다. 서버가 대표 이미지를 따로 주지 않는다 - `receivers` 를 DTO·도메인까지 실어 시안의 "수신인 OOO" 줄을 채운다 목록 화면의 `onItemClick` 에는 기본값을 두지 않았다. 실제로 이 작업 중 일기 탭 결선을 빠뜨렸는데 기본값 `{}` 때문에 컴파일이 통과해 카드가 눌리지 않는 채로 굴러갔다. Co-Authored-By: Claude Opus 5 (1M context) --- .../navigation/AppNavigationActions.kt | 14 + .../mindrecord/data/dto/DailyQuestionDto.kt | 2 + .../feature/mindrecord/data/dto/DiaryDto.kt | 10 + .../data/mapper/DailyQuestionMapper.kt | 1 + .../mindrecord/data/mapper/DiaryMapper.kt | 1 + .../domain/model/DailyQuestionModels.kt | 2 + .../mindrecord/domain/model/DiaryModels.kt | 2 + .../component/DailyQuestionListCard.kt | 4 +- .../presentation/component/DiaryCard.kt | 5 +- .../presentation/component/DiaryComponent.kt | 4 +- .../navigation/MindRecordNavActions.kt | 7 + .../navigation/MindRecordNavGraph.kt | 16 ++ .../navigation/MindRecordRoute.kt | 13 + .../sender/DailyQuestionAnswerListScreen.kt | 13 +- .../presentation/screen/sender/DiaryScreen.kt | 9 + .../presentation/screen/sender/HomeScreen.kt | 12 +- .../screen/sender/RecordDetailScreen.kt | 264 ++++++++++++++++++ .../mindrecord/presentation/util/HtmlText.kt | 50 ++++ .../viewmodel/RecordDetailUiState.kt | 34 +++ .../viewmodel/RecordDetailViewModel.kt | 97 +++++++ .../src/main/res/values/strings.xml | 6 + .../util/RecordContentBlockTest.kt | 78 ++++++ .../viewmodel/RecordDetailHeroImageTest.kt | 63 +++++ 23 files changed, 701 insertions(+), 6 deletions(-) create mode 100644 feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/RecordDetailScreen.kt create mode 100644 feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailUiState.kt create mode 100644 feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailViewModel.kt create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/RecordContentBlockTest.kt create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailHeroImageTest.kt 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 8fb2ef82c9..50d815b40d 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 @@ -124,6 +124,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 onEditDiaryDraft( draftId: Long, draftYearMonth: String, 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 6b48f61d73..f6f72fbab6 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 @@ -41,6 +41,8 @@ data class DailyQuestionListItemDto( @SerialName("isDraft") @JsonNames("draft") val isDraft: Boolean = false, + // 상세 화면의 "수신인 OOO" 표시용 (#759). + @SerialName("receivers") val receivers: List = emptyList(), ) @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 c996fab631..f1e7e47405 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 @@ -79,6 +79,16 @@ data class DiaryListItemDto( @SerialName("isDraft") @JsonNames("draft") val isDraft: Boolean = false, + // 상세 화면이 "수신인 OOO" 로 보여준다. 서버가 늘 함께 내려주는데 종전에는 선언하지 + // 않아 버려졌다 (#759). + @SerialName("receivers") val receivers: List = emptyList(), +) + +/** 기록에 지정된 수신자 요약 (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 f1ac388ccb..d947747560 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 @@ -17,6 +17,7 @@ fun DailyQuestionListItemDto.toDomain(): DailyQuestion = createdAt = createdAt, imageUrl = imageUrl, 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 08bddf111f..8d6f3914e0 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/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 9391bc3639..1a25d8cc6a 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 @@ -7,6 +7,8 @@ data class DailyQuestion( val createdAt: String, val imageUrl: String? = null, 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 4b325fea07..2441211fde 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 @@ -17,6 +17,8 @@ data class Diary( val todayMood: TodayMood?, val imageUrl: String? = null, val isDraft: Boolean = false, + /** 이 기록을 전달받을 수신자 이름들. 상세 화면이 "수신인 OOO" 로 보여준다 (#759). */ + val receiverNames: List = emptyList(), ) data class DiaryList( 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/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/navigation/MindRecordNavActions.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt index eb6213e21b..e6cd612bc5 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 @@ -22,6 +22,13 @@ interface MindRecordNavActions { fun onDraftListBack() + /** 목록 항목 탭 — 저장된 기록 본문을 여는 상세 화면 (#759). */ + fun onOpenRecordDetail( + recordId: Long, + isDiary: Boolean, + yearMonth: String?, + ) + /** 임시저장 목록에서 일기 draft 를 탭 — 일기 작성 화면을 이어쓰기 모드로 연다. */ fun onEditDiaryDraft( draftId: 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 5e2813c196..523d110dd1 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,8 @@ 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 /** * 마인드레코드 피처의 루트 [NavHost] 등록 묶음. @@ -20,6 +23,13 @@ import com.afternote.feature.mindrecord.presentation.screen.sender.HomeScreen fun NavGraphBuilder.mindRecordNavGraph(actions: MindRecordNavActions) { composable { HomeScreen( + onRecordClick = { recordId, isDiary -> + actions.onOpenRecordDetail( + recordId = recordId, + isDiary = isDiary, + yearMonth = YearMonth.now().toString(), + ) + }, onWriteClick = { category -> when (category) { MindRecordCategoryUi.DailyQuestion -> actions.onWriteDailyQuestion() @@ -49,6 +59,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 e4766f0152..d4076fadfd 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 @@ -16,6 +16,19 @@ sealed interface MindRecordRoute { val draftYearMonth: String? = null, ) : 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 49b52afab4..91d3310bb7 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 @@ -35,8 +35,13 @@ import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionList import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionListViewModel import java.time.YearMonth +/** + * @param onItemClick 카드를 눌렀을 때 상세로 보낼 기록 ID. **기본값을 두지 않는다** — + * 기본값이 있으면 호출부에서 빠뜨려도 컴파일이 되고, 카드가 눌리지 않는 채로 나간다 (#759). + */ @Composable fun DailyQuestionAnswerListScreen( + onItemClick: (Long) -> Unit, modifier: Modifier = Modifier, isListView: Boolean = true, viewModel: DailyQuestionListViewModel = hiltViewModel(), @@ -63,6 +68,7 @@ fun DailyQuestionAnswerListScreen( modifier = modifier, isListView = isListView, answers = state.answers, + onItemClick = onItemClick, onDelete = viewModel::delete, ) } @@ -74,6 +80,7 @@ private fun DailyQuestionListContent( isListView: Boolean, answers: List, modifier: Modifier = Modifier, + onItemClick: (Long) -> Unit = {}, onDelete: (Long) -> Unit = {}, ) { var yearMonth by remember { mutableStateOf(YearMonth.now()) } @@ -128,7 +135,11 @@ private fun DailyQuestionListContent( } items(visibleAnswers, key = { it.id }) { answer -> - DailyQuestionListCard(answer = answer, onDelete = { onDelete(answer.id) }) + DailyQuestionListCard( + answer = answer, + onClick = { onItemClick(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 b917c23e8c..4abb8a351d 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 @@ -49,8 +49,13 @@ import androidx.compose.foundation.lazy.staggeredgrid.items as gridItems private val PreviewYearMonth = YearMonth.of(2026, 7) +/** + * @param onItemClick 카드를 눌렀을 때 상세로 보낼 기록 ID. **기본값을 두지 않는다** — + * 기본값이 있으면 호출부에서 빠뜨려도 컴파일이 되고, 카드가 눌리지 않는 채로 나간다 (#759). + */ @Composable fun DiaryScreen( + onItemClick: (Long) -> Unit, modifier: Modifier = Modifier, isListView: Boolean = true, viewModel: DiaryListViewModel = hiltViewModel(), @@ -80,6 +85,7 @@ fun DiaryScreen( yearMonth = state.yearMonth, monthDiaryCount = state.monthDiaryCount, weeklyMoodEmoji = state.weeklyDominantMood?.toEmoji(), + onItemClick = onItemClick, onDelete = viewModel::delete, onYearMonthChanged = viewModel::selectYearMonth, ) @@ -98,6 +104,7 @@ private fun DiaryListContent( modifier: Modifier = Modifier, monthDiaryCount: Int = 0, weeklyMoodEmoji: String? = null, + onItemClick: (Long) -> Unit = {}, onDelete: (Long) -> Unit = {}, onYearMonthChanged: (YearMonth) -> Unit = {}, ) { @@ -163,6 +170,7 @@ private fun DiaryListContent( items(visibleDiaries, key = { it.id }) { diary -> DiaryComponent( diary = diary, + onClick = { onItemClick(diary.id) }, modifier = Modifier.padding(vertical = 8.dp), onDelete = { onDelete(diary.id) }, ) @@ -186,6 +194,7 @@ private fun DiaryListContent( gridItems(diaries, key = { it.id }) { diary -> DiaryCard( diary = diary, + onClick = { onItemClick(diary.id) }, onDelete = { onDelete(diary.id) }, ) } 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 0357ca0e8c..93ac3dc3b8 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 @@ -47,6 +47,8 @@ import com.afternote.feature.mindrecord.presentation.R as MindRecordR fun HomeScreen( modifier: Modifier = Modifier, onWriteClick: (MindRecordCategoryUi) -> Unit = {}, + /** 목록 항목 탭 → 상세(열람) 화면. (기록 ID, 일기 여부) (#759). */ + onRecordClick: (Long, Boolean) -> Unit = { _, _ -> }, ) { // Figma 2757:16116 — 마음의 기록 탭은 데일리 질문 / 일기 / 주간리포트 3개 val categories = @@ -164,11 +166,17 @@ fun HomeScreen( HorizontalPager(state = pagerState) { page -> when (categories[page]) { MindRecordCategoryUi.DailyQuestion -> { - DailyQuestionAnswerListScreen(isListView = dailyQuestionListView) + DailyQuestionAnswerListScreen( + isListView = dailyQuestionListView, + onItemClick = { id -> onRecordClick(id, false) }, + ) } MindRecordCategoryUi.Diary -> { - DiaryScreen(isListView = diaryListView) + DiaryScreen( + isListView = diaryListView, + onItemClick = { id -> onRecordClick(id, true) }, + ) } else -> { 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..dbe23656e1 --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/RecordDetailScreen.kt @@ -0,0 +1,264 @@ +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() + } + } + + 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) + } + Text( + text = state.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/util/HtmlText.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt index 433ee6a82d..46cda13f03 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 @@ -66,3 +66,53 @@ fun String.escapeHtml(): String = private val HTML_TAG = Regex("<[^>]*>") private val HTML_ENTITY = Regex("&[a-zA-Z]+;|&#\\d+;") + +/** + * 상세 화면 본문 블록 (#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/RecordDetailUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailUiState.kt new file mode 100644 index 0000000000..e5666ff852 --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailUiState.kt @@ -0,0 +1,34 @@ +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, + 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 +} 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..bb79ba61cd --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailViewModel.kt @@ -0,0 +1,97 @@ +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 로 다시 불러 대상 하나를 고른다 — 작성 화면의 프리필과 같은 방식이다. + */ +@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) + .mapCatching { list -> list.diaries.first { it.diaryId == route.recordId } } + .onSuccess { diary -> + val ui = diary.toUi() + val blocks = diary.content.toRecordContentBlocks() + _uiState.value = + RecordDetailUiState.Success( + title = diary.title, + date = ui?.date ?: YearMonth.now().atDay(1), + receiverNames = diary.receiverNames, + heroImageUrl = blocks.firstImageUrl(), + blocks = blocks, + mood = diary.todayMood?.toEmoji(), + ) + }.onFailure { failWithLoadError() } + } + } + + private fun loadDailyQuestion() { + viewModelScope.launch { + dailyQuestionRepository + .getList() + .mapCatching { list -> list.first { it.dailyQuestionId == route.recordId } } + .onSuccess { answer -> + 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/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index c00e77b2c6..e9027cd57c 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -184,4 +184,10 @@ 본문을 입력해 주세요. 오늘의 기분을 골라 주세요. 임시저장할 내용이 없어요. + + 수신인 + 오늘의 기분 + 기록을 불러오지 못했어요. + 데일리 질문 + 일기 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/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()) + } +} From ff8fcb6761823f293d1d3e5e1c73e8795888055f Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 22:41:36 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(mindrecord):=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=AA=A9=EB=A1=9D=EC=9D=B4=20=EB=B3=B4=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=EB=8B=AC=EC=9D=84=20=EB=84=98=EA=B8=B4?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 내비게이션이 YearMonth.now() 를 고정으로 넣어, 지난달 기록을 탭하면 상세가 이번 달 목록에서 그 기록을 찾다 실패했다 — 이번 달 기록만 열렸다. 목록은 달을 바꿀 수 있는 화면이라 흔히 밟히는 경로다. 함께 정리한 것: - 날짜를 못 구하면 «이번 달 1일» 을 지어내던 것을 걷고 그 줄을 그리지 않는다. 상세에서 날짜는 부수 정보가 아니라 기록을 식별하는 값이라, 지어내면 사용자가 그 날짜를 믿는다 - «없는 기록» 을 통신 실패와 구분해 NotFound 상태로 둔다. 사용자가 할 일이 다르다 — 통신 실패는 다시 시도, 없는 기록은 목록으로. 종전에는 재시도를 눌러도 같은 화면이었다 - 목록 전체를 받는 이유(단건 조회 API 부재)를 KDoc 에 남긴다 리뷰 지적. Co-Authored-By: Claude Opus 5 (1M context) --- .../navigation/MindRecordNavGraph.kt | 6 +- .../sender/DailyQuestionAnswerListScreen.kt | 9 +-- .../presentation/screen/sender/DiaryScreen.kt | 14 +++-- .../presentation/screen/sender/HomeScreen.kt | 8 ++- .../screen/sender/RecordDetailScreen.kt | 27 +++++++-- .../viewmodel/RecordDetailUiState.kt | 16 ++++- .../viewmodel/RecordDetailViewModel.kt | 18 +++++- .../src/main/res/values/strings.xml | 1 + .../screen/sender/RecordListMonthTest.kt | 59 +++++++++++++++++++ 9 files changed, 134 insertions(+), 24 deletions(-) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/RecordListMonthTest.kt 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 523d110dd1..af04d8fbe9 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 @@ -23,11 +23,13 @@ import java.time.YearMonth fun NavGraphBuilder.mindRecordNavGraph(actions: MindRecordNavActions) { composable { HomeScreen( - onRecordClick = { recordId, isDiary -> + onRecordClick = { recordId, isDiary, yearMonth -> + // 목록이 보고 있던 달을 그대로 넘긴다. 이번 달로 고정하면 지난달 기록이 + // 상세에서 조회되지 않아 통째로 열리지 않는다 (#759 리뷰). actions.onOpenRecordDetail( recordId = recordId, isDiary = isDiary, - yearMonth = YearMonth.now().toString(), + yearMonth = yearMonth.toString(), ) }, onWriteClick = { category -> 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 91d3310bb7..ae554680f5 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 @@ -36,12 +36,13 @@ import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionList import java.time.YearMonth /** - * @param onItemClick 카드를 눌렀을 때 상세로 보낼 기록 ID. **기본값을 두지 않는다** — + * @param onItemClick 카드를 눌렀을 때 상세로 보낼 기록 ID 와 **보고 있는 달** (#759 리뷰). + * 기본값을 두지 않는다 — * 기본값이 있으면 호출부에서 빠뜨려도 컴파일이 되고, 카드가 눌리지 않는 채로 나간다 (#759). */ @Composable fun DailyQuestionAnswerListScreen( - onItemClick: (Long) -> Unit, + onItemClick: (Long, YearMonth) -> Unit, modifier: Modifier = Modifier, isListView: Boolean = true, viewModel: DailyQuestionListViewModel = hiltViewModel(), @@ -80,7 +81,7 @@ private fun DailyQuestionListContent( isListView: Boolean, answers: List, modifier: Modifier = Modifier, - onItemClick: (Long) -> Unit = {}, + onItemClick: (Long, YearMonth) -> Unit = { _, _ -> }, onDelete: (Long) -> Unit = {}, ) { var yearMonth by remember { mutableStateOf(YearMonth.now()) } @@ -137,7 +138,7 @@ private fun DailyQuestionListContent( items(visibleAnswers, key = { it.id }) { answer -> DailyQuestionListCard( answer = answer, - onClick = { onItemClick(answer.id) }, + onClick = { onItemClick(answer.id, yearMonth) }, 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 4abb8a351d..fc7dea0fa9 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 @@ -50,12 +50,14 @@ import androidx.compose.foundation.lazy.staggeredgrid.items as gridItems private val PreviewYearMonth = YearMonth.of(2026, 7) /** - * @param onItemClick 카드를 눌렀을 때 상세로 보낼 기록 ID. **기본값을 두지 않는다** — + * @param onItemClick 카드를 눌렀을 때 상세로 보낼 기록 ID 와 **보고 있는 달**. 달을 함께 + * 싣지 않으면 상세가 이번 달 목록에서 그 기록을 찾다 실패한다 — 목록은 달을 바꿀 수 + * 있으므로 지난달 기록이 통째로 열리지 않는다 (#759 리뷰). 기본값을 두지 않는다 — * 기본값이 있으면 호출부에서 빠뜨려도 컴파일이 되고, 카드가 눌리지 않는 채로 나간다 (#759). */ @Composable fun DiaryScreen( - onItemClick: (Long) -> Unit, + onItemClick: (Long, YearMonth) -> Unit, modifier: Modifier = Modifier, isListView: Boolean = true, viewModel: DiaryListViewModel = hiltViewModel(), @@ -94,7 +96,7 @@ fun DiaryScreen( } @Composable -private fun DiaryListContent( +internal fun DiaryListContent( isListView: Boolean, diaries: List, // 조회 중인 월은 VM 이 들고 있다 — 자동 갱신이 같은 월을 다시 조회해야 하고, @@ -104,7 +106,7 @@ private fun DiaryListContent( modifier: Modifier = Modifier, monthDiaryCount: Int = 0, weeklyMoodEmoji: String? = null, - onItemClick: (Long) -> Unit = {}, + onItemClick: (Long, YearMonth) -> Unit = { _, _ -> }, onDelete: (Long) -> Unit = {}, onYearMonthChanged: (YearMonth) -> Unit = {}, ) { @@ -170,7 +172,7 @@ private fun DiaryListContent( items(visibleDiaries, key = { it.id }) { diary -> DiaryComponent( diary = diary, - onClick = { onItemClick(diary.id) }, + onClick = { onItemClick(diary.id, yearMonth) }, modifier = Modifier.padding(vertical = 8.dp), onDelete = { onDelete(diary.id) }, ) @@ -194,7 +196,7 @@ private fun DiaryListContent( gridItems(diaries, key = { it.id }) { diary -> DiaryCard( diary = diary, - onClick = { onItemClick(diary.id) }, + 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/HomeScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt index 93ac3dc3b8..2262454842 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 @@ -41,6 +41,7 @@ import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryListViewMode 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 @@ -48,7 +49,8 @@ fun HomeScreen( modifier: Modifier = Modifier, onWriteClick: (MindRecordCategoryUi) -> Unit = {}, /** 목록 항목 탭 → 상세(열람) 화면. (기록 ID, 일기 여부) (#759). */ - onRecordClick: (Long, Boolean) -> Unit = { _, _ -> }, + /** 기록 상세로 — (기록 ID, 일기 여부, 목록이 보고 있던 달). */ + onRecordClick: (Long, Boolean, YearMonth) -> Unit = { _, _, _ -> }, ) { // Figma 2757:16116 — 마음의 기록 탭은 데일리 질문 / 일기 / 주간리포트 3개 val categories = @@ -168,14 +170,14 @@ fun HomeScreen( MindRecordCategoryUi.DailyQuestion -> { DailyQuestionAnswerListScreen( isListView = dailyQuestionListView, - onItemClick = { id -> onRecordClick(id, false) }, + onItemClick = { id, yearMonth -> onRecordClick(id, false, yearMonth) }, ) } MindRecordCategoryUi.Diary -> { DiaryScreen( isListView = diaryListView, - onItemClick = { id -> onRecordClick(id, true) }, + onItemClick = { id, yearMonth -> onRecordClick(id, true, yearMonth) }, ) } 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 index dbe23656e1..9ae2997a46 100644 --- 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 @@ -84,6 +84,19 @@ fun RecordDetailScreen( } } + 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), @@ -210,11 +223,15 @@ private fun DetailHero(state: RecordDetailUiState.Success) { } else { Box(modifier = Modifier) } - Text( - text = state.date.format(DetailDateFormatter), - style = AfternoteDesign.typography.footnoteCaption, - color = metaColor, - ) + // 날짜를 못 구하면 줄을 그리지 않는다 — 지어낸 날짜를 그리면 사용자는 그 + // 기록이 실제로 그 날 쓰인 것으로 읽는다 (#759 리뷰). + state.date?.let { date -> + Text( + text = date.format(DetailDateFormatter), + style = AfternoteDesign.typography.footnoteCaption, + color = metaColor, + ) + } } } } 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 index e5666ff852..8b1f8f31c2 100644 --- 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 @@ -15,7 +15,12 @@ sealed interface RecordDetailUiState { data class Success( val title: String, - val date: LocalDate, + /** + * 기록 날짜. **없으면 null 이고 그 줄을 그리지 않는다** — 지어낸 날짜를 그리면 + * 사용자는 그 기록이 실제로 그 날 쓰인 것으로 읽는다. 상세에서 날짜는 부수 + * 정보가 아니라 기록을 식별하는 값이다 (#759 리뷰). + */ + val date: LocalDate?, /** "수신인 OOO" 로 보여줄 이름들. 지정하지 않았으면 빈 목록이라 줄 자체를 그리지 않는다. */ val receiverNames: List, /** @@ -31,4 +36,13 @@ sealed interface 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 index bb79ba61cd..063364099a 100644 --- 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 @@ -26,6 +26,10 @@ import javax.inject.Inject * * 목록이 이미 본문을 갖고 있지만, 화면을 옮기면 그 상태가 따라오지 않으므로 같은 목록 * API 로 다시 불러 대상 하나를 고른다 — 작성 화면의 프리필과 같은 방식이다. + * + * **단건 조회 API 가 없어서** 목록 전체를 받는다. 요청 수를 줄이는 방향(#736)과 반대로 + * 보이지만 대안이 없다 — `GET /diary`·`GET /daily-questions` 만 있고 `/{id}` 가 없다. + * 서버에 단건 조회가 생기면 이 왕복이 사라진다. */ @HiltViewModel class RecordDetailViewModel @@ -48,14 +52,18 @@ class RecordDetailViewModel viewModelScope.launch { diaryRepository .getList(yearMonth = route.yearMonth ?: YearMonth.now().toString(), draftOnly = null) - .mapCatching { list -> list.diaries.first { it.diaryId == route.recordId } } + .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 ?: YearMonth.now().atDay(1), + date = ui?.date, receiverNames = diary.receiverNames, heroImageUrl = blocks.firstImageUrl(), blocks = blocks, @@ -69,8 +77,12 @@ class RecordDetailViewModel viewModelScope.launch { dailyQuestionRepository .getList() - .mapCatching { list -> list.first { it.dailyQuestionId == route.recordId } } + .map { list -> list.firstOrNull { it.dailyQuestionId == route.recordId } } .onSuccess { answer -> + if (answer == null) { + _uiState.value = RecordDetailUiState.NotFound + return@onSuccess + } val ui = answer.toUi() val blocks = answer.content.toRecordContentBlocks() _uiState.value = diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index e9027cd57c..7dffba2d95 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -187,6 +187,7 @@ 수신인 오늘의 기분 + 기록을 찾을 수 없어요. 목록에서 다시 열어 주세요. 기록을 불러오지 못했어요. 데일리 질문 일기 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) + } +} From ff458fdff6b668178c1bb9bd35f91714dc6ee53c Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 22:42:07 +0900 Subject: [PATCH 3/3] =?UTF-8?q?style(mindrecord):=20=EC=A4=91=EB=B3=B5=20K?= =?UTF-8?q?Doc=20=EC=9D=84=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- .../presentation/screen/sender/HomeScreen.kt | 3 +-- .../viewmodel/DailyQuestionListViewModel.kt | 7 ------- .../presentation/viewmodel/DiaryListViewModel.kt | 10 ---------- 3 files changed, 1 insertion(+), 19 deletions(-) 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 2262454842..94bf2017df 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 @@ -48,8 +48,7 @@ import com.afternote.feature.mindrecord.presentation.R as MindRecordR fun HomeScreen( modifier: Modifier = Modifier, onWriteClick: (MindRecordCategoryUi) -> Unit = {}, - /** 목록 항목 탭 → 상세(열람) 화면. (기록 ID, 일기 여부) (#759). */ - /** 기록 상세로 — (기록 ID, 일기 여부, 목록이 보고 있던 달). */ + // 목록 항목 탭 → 상세(열람) 화면. (기록 ID, 일기 여부, 목록이 보고 있던 달) (#759). onRecordClick: (Long, Boolean, YearMonth) -> Unit = { _, _, _ -> }, ) { // Figma 2757:16116 — 마음의 기록 탭은 데일리 질문 / 일기 / 주간리포트 3개 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 811085c7d9..bdccc7e008 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 @@ -44,13 +44,6 @@ class DailyQuestionListViewModel load() } - /** - * 탭 전환·작성 화면 복귀 등 사용자가 요청하지 않은 자동 갱신. - * - * 화면이 살아 있는 채로 발화하므로 로딩을 방출하지 않고(콘텐츠가 통째 교체되면 - * 캘린더 월과 스크롤 위치가 함께 폐기된다) 실패해도 보고 있던 화면을 유지한다. - */ - /** * 탭 전환·`ON_RESUME` 등 사용자가 요청하지 않은 자동 갱신. * 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 f93509ca65..bf63a33242 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 @@ -52,16 +52,6 @@ class DiaryListViewModel load(yearMonth) } - /** - * 탭 전환·작성 화면 복귀 등 사용자가 요청하지 않은 자동 갱신. - * - * [selectYearMonth] 와 세 가지가 다르다. - * - 로딩을 방출하지 않는다. 화면이 살아 있는 채로 발화하므로 콘텐츠가 통째 교체되면 - * 캘린더 월과 스크롤 위치가 함께 폐기된다. - * - 실패해도 보고 있던 화면을 유지한다. - * - 보고 있던 월을 그대로 다시 조회한다. 6월을 보는 중에 7월 데이터를 받으면 안 된다. - */ - /** * 탭 전환·`ON_RESUME` 등 사용자가 요청하지 않은 자동 갱신. *