diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt index 0cb135632e..d03c75dac6 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt @@ -93,7 +93,7 @@ class MindRecordLifecycleAndroidTest { composeRule.setContent { AfternoteTheme { - DiaryScreen(viewModel = viewModel) + DiaryScreen(viewModel = viewModel, onItemClick = { _, _ -> }) } } 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 2675d2bd1c..dad8cae4a5 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -338,7 +338,7 @@ class TimeLetterMindRecordCompletionAndroidTest { composeRule.setContent { AfternoteTheme { - DailyQuestionAnswerListScreen(viewModel = activeViewModel) + DailyQuestionAnswerListScreen(viewModel = activeViewModel, onItemClick = { _, _ -> }) } } @@ -394,7 +394,7 @@ class TimeLetterMindRecordCompletionAndroidTest { val activeWriteViewModel = writeViewModel AfternoteTheme { if (activeWriteViewModel == null) { - DailyQuestionAnswerListScreen(viewModel = listViewModel) + DailyQuestionAnswerListScreen(viewModel = listViewModel, onItemClick = { _, _ -> }) } else { DailyQuestionWriteScreen( viewModel = activeWriteViewModel, 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 2b706fe469..a2564aa04e 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 @@ -128,6 +128,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, @@ -375,10 +389,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 63e8d05af6..8b08ada16d 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() {} @@ -290,9 +286,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 11a017deea..945381c7b1 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 6f95394229..9de4d0f37e 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 dc1ab58bbf..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/dto/DailyQuestionDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt index b379db34d8..1e08eb7de2 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 @@ -49,9 +49,12 @@ data class DailyQuestionListItemDto( // // 기본값 `false` 는 두지 않는다 — 서버가 항상 보내는 키라, 빠졌을 때 "임시저장 아님" 으로 // 접히면 임시저장 글이 목록에 그대로 노출된다. 계약 누락은 파싱 실패로 드러나야 한다 (#789). + // 기본값을 두지 않는다 — 키가 빠지면 false 로 접혀 임시저장이 목록에 샌다 (#789). @SerialName("isDraft") @JsonNames("draft") val isDraft: Boolean, + // 상세 화면의 "수신인 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 5826c340d8..5dbc8dea78 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 = 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 46e89315dd..52a2b6e59b 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/build.gradle.kts b/feature/mindrecord/domain/build.gradle.kts index f32f46ad0e..e6085e4611 100644 --- a/feature/mindrecord/domain/build.gradle.kts +++ b/feature/mindrecord/domain/build.gradle.kts @@ -8,4 +8,6 @@ android { dependencies { implementation(libs.coroutines.core) + + testImplementation(libs.coroutines.test) } 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 9ac8f68e14..d3431fa0a8 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/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/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/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/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 937e6d7cf6..71f977620e 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 @@ -20,11 +20,13 @@ import androidx.compose.runtime.getValue 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.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.SpanStyle @@ -40,8 +42,10 @@ 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.mohamedrejeb.richeditor.model.rememberRichTextState import com.mohamedrejeb.richeditor.ui.BasicRichTextEditor +import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** @@ -91,10 +95,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) { @@ -201,7 +213,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)) } }, @@ -240,8 +263,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 }, @@ -282,3 +308,6 @@ private fun WriteTextFieldPreview() { WriteTextField() } } + +/** T 로 키보드를 올릴 때 IME 가 올라오기를 기다리는 시간 (#722). */ +private const val KEYBOARD_SETTLE_MS = 300L 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 6b7fefc2aa..3a1fab02a9 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?, + ) + /** 임시저장 목록에서 일기 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 6632640691..89ebdcff39 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,15 @@ import com.afternote.feature.mindrecord.presentation.screen.sender.HomeScreen 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() @@ -50,6 +62,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 529e1911f7..218ff193d4 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 @@ -38,8 +38,14 @@ 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, viewModel: DailyQuestionListViewModel = hiltViewModel(), @@ -67,6 +73,7 @@ fun DailyQuestionAnswerListScreen( isListView = isListView, todayQuestion = state.todayQuestion, answers = state.answers, + onItemClick = onItemClick, onDelete = viewModel::delete, ) } @@ -79,6 +86,7 @@ private fun DailyQuestionListContent( answers: List, modifier: Modifier = Modifier, todayQuestion: TodayQuestionUi? = null, + onItemClick: (Long, YearMonth) -> Unit = { _, _ -> }, onDelete: (Long) -> Unit = {}, ) { var yearMonth by remember { mutableStateOf(YearMonth.now()) } @@ -156,7 +164,11 @@ private fun DailyQuestionListContent( } items(visibleAnswers, key = { it.id }) { answer -> - DailyQuestionListCard(answer = answer, onDelete = { onDelete(answer.id) }) + DailyQuestionListCard( + answer = answer, + 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 38b7bb7b98..b6447c4aa6 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,8 +50,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, viewModel: DiaryListViewModel = hiltViewModel(), @@ -81,6 +88,7 @@ fun DiaryScreen( yearMonth = state.yearMonth, monthDiaryCount = state.monthDiaryCount, weeklyMoodEmoji = state.weeklyDominantMood?.toEmoji(), + onItemClick = onItemClick, onDelete = viewModel::delete, onYearMonthChanged = viewModel::selectYearMonth, ) @@ -89,7 +97,7 @@ fun DiaryScreen( } @Composable -private fun DiaryListContent( +internal fun DiaryListContent( isListView: Boolean, diaries: List, // 조회 중인 월은 VM 이 들고 있다 — 자동 갱신이 같은 월을 다시 조회해야 하고, @@ -99,6 +107,7 @@ private fun DiaryListContent( modifier: Modifier = Modifier, monthDiaryCount: Int = 0, weeklyMoodEmoji: String? = null, + onItemClick: (Long, YearMonth) -> Unit = { _, _ -> }, onDelete: (Long) -> Unit = {}, onYearMonthChanged: (YearMonth) -> Unit = {}, ) { @@ -156,6 +165,7 @@ private fun DiaryListContent( items(visibleDiaries, key = { it.id }) { diary -> DiaryComponent( diary = diary, + onClick = { onItemClick(diary.id, yearMonth) }, modifier = Modifier.padding(vertical = 8.dp), onDelete = { onDelete(diary.id) }, ) @@ -179,6 +189,7 @@ private fun DiaryListContent( gridItems(diaries, key = { it.id }) { diary -> DiaryCard( 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 b3e286903f..e7ef8908db 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 @@ -82,14 +82,27 @@ fun DiaryWriteScreen( title = stringResource(MindRecordR.string.mindrecord_diary_write_title), onBackClick = onBackClick, actions = { + // 완성되지 않아도 **누를 수는 있게** 둔다. 비활성이면 submit() 이 아예 + // 돌지 않아 무엇이 빠졌는지 알릴 자리가 없다 — 회색 버튼만으로는 고장과 + // 구분되지 않는다 (#722). 색은 종전대로 미완성일 때 흐리게 둔다. Button( onClick = { viewModel.submit() }, - enabled = uiState.canSubmit, + 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 0d2a245268..9462c8c277 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,7 @@ 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 @@ -39,12 +40,16 @@ 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, onWriteClick: (MindRecordCategoryUi) -> Unit = {}, + // 목록 항목 탭 → 상세(열람) 화면. (기록 ID, 일기 여부, 목록이 보고 있던 달) (#759). + onRecordClick: (Long, Boolean, YearMonth) -> Unit = { _, _, _ -> }, ) { // Figma 2757:16116 — 마음의 기록 탭은 데일리 질문 / 일기 / 주간리포트 3개 val categories = @@ -56,8 +61,16 @@ fun HomeScreen( ) } - var selectedIndex by remember { mutableIntStateOf(0) } + // 선택 상태의 단일 출처는 pager 다. 종전에는 selectedIndex 로 애니메이션을 시작하면서 + // pagerState.currentPage 를 다시 selectedIndex 에 기록해, 0↔2 전환 중 거쳐 가는 1번 + // 페이지가 선택값을 덮어써 가운데 탭(일기)에 멈췄다 (#722). + // + // 탭 클릭은 pager 에게 "거기로 가라" 고만 말하고, 화면이 읽는 값은 pager 의 + // settledPage 하나뿐이다 — 스크롤 도중의 중간 페이지가 선택으로 굳지 않는다. + val pagerState = rememberPagerState { categories.size } + val selectedIndex = pagerState.settledPage val selectedCategory = categories[selectedIndex] + val scope = rememberCoroutineScope() // 탭마다 따로 기억한다. 종전에는 한 값을 두 탭이 공유했는데 의미가 서로 반대였다 — // 데일리질문은 false 를 캘린더로, 일기는 같은 false 를 2열 그리드로 썼다. 그래서 탭을 @@ -70,14 +83,6 @@ fun HomeScreen( else -> diaryListView } - val pagerState = rememberPagerState { categories.size } - LaunchedEffect(selectedIndex) { - pagerState.animateScrollToPage(selectedIndex) - } - LaunchedEffect(pagerState.currentPage) { - selectedIndex = pagerState.currentPage - } - // 탭 VM 은 **여기서 만들지 않는다.** 호이스팅하면 선택하지 않은 탭의 `init` 조회까지 // 진입 즉시 나간다 — 마음의 기록 첫 진입 한 번에 요청이 7건 나간 가장 큰 원인이었다. // 특히 주간리포트는 서버가 같은 주차의 반복 GET 에도 Gemini 를 다시 호출하므로 @@ -147,7 +152,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), @@ -160,14 +169,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 = dailyQuestionListView) + DailyQuestionAnswerListScreen( + isListView = dailyQuestionListView, + onItemClick = { id, yearMonth -> onRecordClick(id, false, yearMonth) }, + ) } MindRecordCategoryUi.Diary -> { - DiaryScreen(isListView = diaryListView) + DiaryScreen( + isListView = diaryListView, + onItemClick = { id, yearMonth -> onRecordClick(id, true, yearMonth) }, + ) } 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..9ae2997a46 --- /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/util/HtmlText.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt index 1d318b361a..46f65cde03 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 로 변환. @@ -45,3 +46,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/DailyQuestionWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt index 55c2580fc2..f4d76f6772 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 @@ -1,6 +1,7 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.presentation.util.isHtmlBlank data class DailyQuestionWriteUiState( val questionId: Long? = null, @@ -33,7 +34,9 @@ data class DailyQuestionWriteUiState( */ val canSubmit: Boolean get() = - answer.isNotBlank() && + // 리치 에디터는 빈 문단도 `

`·`
` 로 직렬화한다. isNotBlank() 로 판정하면 + // 화면이 비어 있어도 통과해 빈 답변이 저장되고 작성 화면이 pop 됐다 (#722). + !answer.isHtmlBlank() && !isQuestionLoading && // 이어쓸 본문이 도착하기 전에 저장하면 draftId 가 아직 null 이라 POST 로 나가고, // 서버가 questionId upsert 라 기존 임시저장 본문이 덮인다 — #923 과 같은 유실이다. 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 c9f950b5ba..f16ee50218 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 @@ -151,7 +151,9 @@ class DailyQuestionWriteViewModel // 차단은 화면이 아니라 여기서 지킨다. if (state.isResumingDraft) return - if (state.answer.isBlank()) { + // 화면상 본문이 비어 있으면 저장 요청도 화면 이탈도 막는다. 직렬화된 HTML 에 + // isBlank() 를 걸면 `

` 가 통과해 빈 답변이 저장됐다 (#722). + 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/DiaryWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt index 6ac03e8303..e32a8fc4ef 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( @@ -24,13 +26,49 @@ data class DiaryWriteUiState( /** 툴바 "임시저장 N" 표시값. `null` 은 아직 모름(조회 중·실패) (#769). */ val draftCount: Int? = null, ) { + /** 정식 등록 조건 — 제목·본문·기분이 모두 있어야 한다. */ val canSubmit: Boolean - get() = - title.isNotBlank() && - content.isNotBlank() && - mood != null && - submitState != SubmitState.InProgress && - !isDraftLoading + 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() = submitState != SubmitState.InProgress && !isDraftLoading + + /** + * 등록을 막는 첫 번째 누락 항목 (없으면 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 d6293dac3a..77ffe944af 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 @@ -101,7 +102,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 { @@ -158,6 +178,14 @@ class DiaryWriteViewModel } // 실패해도 수신자 행이 "수신자 설정하기" 로 남을 뿐 작성 자체는 가능 — 에러는 조용히 무시. + private fun failSubmit( + @StringRes messageRes: Int, + ) { + _uiState.update { + it.copy(submitState = SubmitState.Failed(UiText.Resource(messageRes))) + } + } + private fun loadReceivers() { viewModelScope.launch { runCatching { userRepository.getReceivers() } 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/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index 20226848b9..512831705a 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -194,9 +194,23 @@ %1$d월 %2$d주차 리포트를 불러오지 못했어요. 다시 시도 + + http:// 또는 https:// 로 시작하는 주소를 입력해 주세요. + + 제목을 입력해 주세요. + 본문을 입력해 주세요. + 오늘의 기분을 골라 주세요. + 임시저장할 내용이 없어요. 아직 열람할 수 있는 기록이 없어요.\n작성자가 전달 시점을 정하면 이곳에서 볼 수 있어요. 마음의 기록을 불러오지 못했어요. 다시 시도 마음의 기록 + + 수신인 + 오늘의 기분 + 기록을 찾을 수 없어요. 목록에서 다시 열어 주세요. + 기록을 불러오지 못했어요. + 데일리 질문 + 일기 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/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/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()) + } +}