diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/button/FAB/AfternoteFloatingActionButton.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/button/FAB/AfternoteFloatingActionButton.kt index 39199d72cb..686fed122c 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/button/FAB/AfternoteFloatingActionButton.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/button/FAB/AfternoteFloatingActionButton.kt @@ -24,7 +24,7 @@ import com.afternote.core.ui.theme.AfternoteDesign fun AfternoteFloatingActionButton( onClick: () -> Unit, modifier: Modifier = Modifier, - size: Dp = 56.dp, + size: Dp = AfternoteFabSize, iconSize: Dp = 24.dp, ) { FloatingActionButton( @@ -41,3 +41,22 @@ fun AfternoteFloatingActionButton( ) } } + +/** FAB 기본 지름. M3 [FloatingActionButton] 기본값과 같다. */ +val AfternoteFabSize: Dp = 56.dp + +/** M3 `Scaffold` 가 FAB 과 화면 가장자리 사이에 두는 기본 여백. */ +private val FabScreenMargin: Dp = 16.dp + +/** + * FAB 이 뜬 화면의 목록이 **마지막 항목을 가리지 않도록** 남겨야 할 하단 여백. + * + * `Scaffold` 의 `paddingValues` 는 FAB 자리를 예약하지 않는다 — FAB 은 콘텐츠 위에 뜨는 것이 + * 설계다. 그래서 목록이 스스로 이만큼을 비워야 한다. 항목이 많아 스크롤이 생기면 밀어서 볼 + * 수라도 있지만, **항목이 한둘이라 스크롤 자체가 없으면 가려진 채 고정된다** — 기록이 적은 + * 신규 사용자가 정확히 그 상태다. + * + * FAB 지름에 위아래 기본 여백(M3 `Scaffold` 가 FAB 에 주는 16dp)을 더한 값이라, 지름이 + * 바뀌면 여백도 함께 따라온다 — 두 값이 따로 놀지 않게 한 자리에서 낸다. + */ +val AfternoteFabContentBottomPadding: Dp = AfternoteFabSize + FabScreenMargin * 2 diff --git a/docs/qa/assumptions.md b/docs/qa/assumptions.md index 26f0bfb842..5e549a0484 100644 --- a/docs/qa/assumptions.md +++ b/docs/qa/assumptions.md @@ -600,6 +600,29 @@ Figma 노드 `552:19970` 로 확인하려 했으나 데스크톱 앱에 해당 - **드래그 손잡이** — 클릭 액션을 material3 가 얹는 슬롯이다. 이름은 우리가 채웠고(비어 있었다), Role 은 주지 않았다 — 버튼도 선택도 아니라 붙이면 오히려 틀리게 읽힌다. +## #1712 — 일기 탭 문구 2건은 시안에서 **의도적으로 이탈**했다 + +**정정.** 이 절은 종전에 「시안 조회에 실패해 추측했다」고 적었는데 사실과 다르다. 리뷰에서 +정본이 열려 실측됐고(2026-09-03 재확인), **시안은 일기 탭에도 데일리질문과 같은 문구를 쓴다.** + +| 자리 | 정본 시안 | 이 저장소 | +|---|---|---| +| 캘린더 카운트 | `18개의 답변 완료` | `N개의 기록 완료` | +| 목록 섹션 헤더 | `DAILY ANSWER` | `DAILY DIARY` | + +정본 페이지 일기_캘린더 형 [`4327:77693`](https://www.figma.com/design/UP9ZR186jHvRBicjA2SOea/?node-id=4327-77693) +(감정 표시 없는 판 `4327-77678` 도 같다). + +**그럼에도 이탈을 유지한다.** 일기는 질문에 답한 것이 아니라 그날을 적은 것이고, 「답변」은 이 앱이 +데일리질문에만 쓰는 말이다(`오늘의 질문`·`답변 완료`). 같은 화면의 데일리질문 탭이 바로 옆에서 +같은 문구를 쓰고 있어, 두 탭이 같은 말을 하면 무엇이 다른 탭인지 사라진다. 데일리질문 프레임의 +문구가 일기 프레임으로 복사되며 함께 넘어온 것으로 본다. + +**확정은 아니다 — 디자인 회신이 필요하다.** 회신이 「시안대로」면 문자열 두 개 +(`mindrecord_calendar_recorded_count`·`mindrecord_diary_list_section_header`)만 되돌리면 된다. +헤더를 아예 없애는 판정이 나와도 그 자리만 지우면 된다. 리터럴을 리소스로 옮기는 것 자체는 +어느 판정에서도 남는다. + ## #1548 — MEMORY SPACE 자간을 토큰이 아니라 이 자리에서 덮은 이유 이슈가 「토큰을 손댈지 이 화면만 `copy` 할지」를 판정으로 넘겼다. **이 자리에서만 덮었다.** diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyCalendar.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyCalendar.kt index b072dafd7f..5df56a3cb1 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyCalendar.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyCalendar.kt @@ -74,7 +74,19 @@ fun DailyCalendar( ) } Text( - text = stringResource(MindRecordR.string.mindrecord_calendar_answered_count, answeredDays.size), + // 같은 캘린더를 두 탭이 쓰는데 문구가 하나뿐이라, 일기 탭에서도 + // 「N개의 답변 완료」로 읽혔다 — 일기는 답변이 아니다 (#1712). + text = + stringResource( + // 캘린더를 쓰는 것은 두 탭뿐이지만 sealed 를 열거해 둔다 — else 로 두면 + // 새 카테고리가 조용히 「기록 완료」로 떨어진다 (#1712 리뷰). + when (type) { + MindRecordCategoryUi.DailyQuestion -> MindRecordR.string.mindrecord_calendar_answered_count + MindRecordCategoryUi.Diary -> MindRecordR.string.mindrecord_calendar_recorded_count + MindRecordCategoryUi.WeeklyReport -> MindRecordR.string.mindrecord_calendar_recorded_count + }, + answeredDays.size, + ), style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.black.copy(alpha = 0.35f), ) 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 4f1f9a1310..e092d6c184 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 @@ -3,6 +3,7 @@ package com.afternote.feature.mindrecord.presentation.screen.sender import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height @@ -24,6 +25,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.asString +import com.afternote.core.ui.button.FAB.AfternoteFabContentBottomPadding import com.afternote.core.ui.loading.LoadingBody import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme @@ -167,6 +169,9 @@ private fun DailyQuestionListContent( LazyColumn( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp), + // FAB 이 콘텐츠 위에 뜨므로 목록이 스스로 그 자리를 비운다 — 안 그러면 마지막 + // 항목이 가려지고, 스크롤이 없을 만큼 항목이 적으면 볼 방법이 없다 (#1713). + contentPadding = PaddingValues(bottom = AfternoteFabContentBottomPadding), ) { // Figma 2671:15631 — 답변 목록 위의 "오늘의 추천 질문" 영역. item { 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 94bcdbd384..8ace1a0e89 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 @@ -3,6 +3,7 @@ package com.afternote.feature.mindrecord.presentation.screen.sender import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -32,6 +33,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteSectionHeader import com.afternote.core.ui.asString +import com.afternote.core.ui.button.FAB.AfternoteFabContentBottomPadding import com.afternote.core.ui.loading.LoadingBody import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme @@ -158,7 +160,12 @@ internal fun DiaryListContent( ?: currentMonthDiaries if (isListView) { - LazyColumn(modifier = modifier) { + LazyColumn( + modifier = modifier, + // FAB 이 콘텐츠 위에 뜨므로 목록이 스스로 그 자리를 비운다 — 안 그러면 마지막 + // 항목이 가려지고, 스크롤이 없을 만큼 항목이 적으면 볼 방법이 없다 (#1713). + contentPadding = PaddingValues(bottom = AfternoteFabContentBottomPadding), + ) { item { DailyCalendar( year = yearMonth.year, @@ -178,7 +185,8 @@ internal fun DiaryListContent( item { // core 정본을 쓴다. 종전에는 같은 구조를 인라인으로 다시 적어, 색이 // black.copy(0.4f)·M3 baseline divider 로 토큰에서 벗어나 있었다 (#634). - AfternoteSectionHeader(title = "DAILY ANSWER") + // 일기 목록인데 데일리질문 헤더가 붙어 있었고, 문자열도 코드 리터럴이었다 (#1712). + AfternoteSectionHeader(title = stringResource(R.string.mindrecord_diary_list_section_header)) Spacer(modifier = Modifier.height(10.dp)) } @@ -204,6 +212,9 @@ internal fun DiaryListContent( } else { // Figma 2671:16732 — 일기 카드 형: 리포트 카드 + 2열 masonry 그리드 LazyVerticalStaggeredGrid( + // FAB 이 콘텐츠 위에 뜨므로 목록이 스스로 그 자리를 비운다 — 안 그러면 마지막 + // 항목이 가려지고, 스크롤이 없을 만큼 항목이 적으면 볼 방법이 없다 (#1713). + contentPadding = PaddingValues(bottom = AfternoteFabContentBottomPadding), modifier = modifier, columns = StaggeredGridCells.Fixed(2), verticalItemSpacing = 8.dp, diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index 849e7e2356..aa7f2019ea 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -71,6 +71,7 @@ %1$d년 %2$d월 %d개의 답변 완료 + %d개의 기록 완료 일 월 화 @@ -78,12 +79,16 @@ 목 금 토 + 기록 날짜 + + DAILY DIARY + 오늘 하루, \n누구에게 가장 고마웠나요? 답변하러가기 diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/CalendarSelectionTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/CalendarSelectionTest.kt index 4781882d04..41bdc0042e 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/CalendarSelectionTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/CalendarSelectionTest.kt @@ -88,6 +88,8 @@ class CalendarSelectionTest { } composeRule.onNodeWithText("2026년 8월").assertIsDisplayed() - composeRule.onNodeWithText("0개의 답변 완료").assertIsDisplayed() + // 일기 탭이므로 「기록」이다 — 종전에는 두 탭이 문구를 공유해 여기서도 「답변」이었고, + // 이 단언이 그 오문을 기대값으로 굳히고 있었다 (#1712). + composeRule.onNodeWithText("0개의 기록 완료").assertIsDisplayed() } } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/ListScreenCopyAndFabSpaceTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/ListScreenCopyAndFabSpaceTest.kt new file mode 100644 index 0000000000..91d41d5ace --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/ListScreenCopyAndFabSpaceTest.kt @@ -0,0 +1,124 @@ +package com.afternote.feature.mindrecord.presentation.screen.sender + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.button.FAB.AfternoteFabContentBottomPadding +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.presentation.component.DailyCalendar +import com.afternote.feature.mindrecord.presentation.model.DailyDiary +import com.afternote.feature.mindrecord.presentation.model.MindRecordCategoryUi +import org.junit.Assert.assertTrue +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.LocalDate +import java.time.YearMonth + +/** + * 목록 화면의 **문구**와 **FAB 자리** 가드 (#1712 · #1713). + * + * 둘 다 #269 QA 회귀를 실기에서 돌다가 나온 것이다. 컴파일도 기존 테스트도 잡지 못했다 — + * 문구는 타입이 같고, 가림은 레이아웃이라 단언할 대상이 없었다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "w360dp-h800dp-xhdpi") +class ListScreenCopyAndFabSpaceTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + /** + * 같은 캘린더를 두 탭이 쓰는데 카운트 문구가 하나뿐이라, 일기 탭에서도 「N개의 **답변** + * 완료」로 읽혔다. 두 탭을 **각각** 렌더해 서로의 문구를 쓰지 않는 것까지 본다 — + * 한쪽만 보면 둘을 도로 합쳐도 통과한다. + */ + @Test + fun `캘린더 카운트 문구가 탭마다 다르다`() { + renderCalendar(MindRecordCategoryUi.DailyQuestion) + composeRule.onNodeWithText("1개의 답변 완료").assertIsDisplayed() + composeRule.onNodeWithText("1개의 기록 완료").assertDoesNotExist() + + composeRule.runOnIdle { category.value = MindRecordCategoryUi.Diary } + composeRule.onNodeWithText("1개의 기록 완료").assertIsDisplayed() + composeRule.onNodeWithText("1개의 답변 완료").assertDoesNotExist() + } + + /** 일기 목록인데 데일리질문 헤더(`DAILY ANSWER`)가 붙어 있었다. */ + @Test + fun `일기 목록 섹션 헤더가 일기 문구다`() { + composeRule.setContent { + AfternoteTheme { + DiaryListContent( + isListView = true, + yearMonth = YearMonth.of(2026, 9), + diaries = listOf(diary()), + onItemClick = { _, _ -> }, + onEdit = { _, _ -> }, + onDelete = {}, + onYearMonthChanged = {}, + ) + } + } + + composeRule.onNodeWithText("DAILY ANSWER").assertDoesNotExist() + composeRule.onNodeWithText("DAILY DIARY").assertIsDisplayed() + } + + /** + * FAB 은 `Scaffold` 가 콘텐츠 위에 띄우고 자리를 예약해 주지 않는다. 목록이 스스로 + * 비우지 않으면 마지막 항목이 가려지는데, **스크롤이 없을 만큼 항목이 적으면 볼 방법이 + * 없다.** 여백이 FAB 을 실제로 덮고도 남는지 값으로 못 박는다. + */ + @Test + fun `목록 하단 여백이 FAB 을 덮고도 남는다`() { + assertTrue( + "FAB(56dp)보다 여백이 작으면 마지막 항목이 가려진다: $AfternoteFabContentBottomPadding", + AfternoteFabContentBottomPadding >= 56.dp, + ) + // 여백만 크면 되는 게 아니라 «FAB 지름 + 위아래 화면 여백» 이어야 바로 위에 붙지 않는다. + assertTrue( + "FAB 바로 위에 항목이 붙는다: $AfternoteFabContentBottomPadding", + AfternoteFabContentBottomPadding >= 56.dp + 16.dp * 2, + ) + } + + private val category = mutableStateOf(MindRecordCategoryUi.DailyQuestion) + + private fun renderCalendar(initial: MindRecordCategoryUi) { + category.value = initial + composeRule.setContent { + AfternoteTheme { + CalendarUnderTest(category.value) + } + } + } + + @Composable + private fun CalendarUnderTest(type: MindRecordCategoryUi) { + DailyCalendar( + year = 2026, + month = 9, + type = type, + onPrevMonth = {}, + onNextMonth = {}, + answeredDays = setOf(1), + emotionByDay = emptyMap(), + selectedDay = null, + onDayClick = {}, + ) + } + + private fun diary() = + DailyDiary( + id = 1L, + title = "일기", + date = LocalDate.of(2026, 9, 1), + content = "본문", + ) +} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/FabContentPaddingKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/FabContentPaddingKonsistTest.kt new file mode 100644 index 0000000000..4ba96baa25 --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/FabContentPaddingKonsistTest.kt @@ -0,0 +1,72 @@ +package com.afternote.konsist + +import org.junit.Test + +/** + * **#1713 이 고친 두 화면**의 하단 여백이 지워지지 않는지. + * + * `Scaffold` 는 FAB 을 콘텐츠 **위에** 띄우고 자리를 예약해 주지 않는다. 목록이 + * `contentPadding` 을 두지 않으면 마지막 항목이 FAB 뒤로 들어가고, 스크롤이 없을 만큼 + * 항목이 적으면 사용자가 밀어낼 방법조차 없다. + * + * 상수값만 단언하는 가드는 **적용 지점을 지워도 통과한다** — 값은 그대로이기 때문이다 + * (#1713 리뷰). 그래서 그 상수를 실제로 쓰는 자리를 파일별로 센다. 레이아웃으로 재는 편이 + * 이상적이지만 Robolectric 에서 LazyColumn 끝까지 스크롤하는 것이 불안정해, 값이 아니라 + * **자리**를 고정하는 쪽을 택했다. + * + * ### 보장 범위 — 이 저장소 전체가 아니다 + * + * [EXPECTED] 에 적은 **두 파일만** 본다. 「FAB 을 띄우는 목록이면 무조건 잡는다」가 아니다. + * 앞으로 생길 화면은 여기 적히기 전까지 그냥 통과한다. 지금도 걸리지 않는 자리가 하나 있다 — + * `feature/receiver/.../recordsbox/ReceivedRecordsScreen.kt` 가 `Scaffold(floatingActionButton = …)` + * 아래 `LazyColumn` 을 `contentPadding` 없이 둔다. 담당 모듈이 달라 #1713 범위 밖이고 별건이다 + * (#1713 리뷰). + * + * 「FAB 을 가진 Scaffold 안의 스크롤 목록」을 구조로 찾아 무는 가드가 본래 옳은 모양이다. + * 그건 `Scaffold` 호출과 그 안 목록의 관계를 파싱해야 해서 이 PR 범위를 넘는다. + * + * 화면이 늘거나 목록이 하나 더 생기면 [EXPECTED] 를 함께 고친다. + */ +class FabContentPaddingKonsistTest { + @Test + fun `#1713 이 고친 두 화면은 하단 여백을 예약한다`() { + val actual = + EXPECTED.keys.associateWith { fileName -> + val file = + AfternoteKonsistScope.files.singleOrNull { it.path.endsWith("/$fileName") } + ?: error("$fileName 을 찾지 못했다 — 파일을 옮겼으면 이 가드도 함께 고칠 것 (#1713).") + // 주석에 적힌 예시가 실제 적용으로 세어지지 않게 본문만 본다 (#1713 리뷰). + CONTENT_PADDING.findAll(file.text.withoutComments()).count() + } + + check(actual == EXPECTED) { + buildString { + appendLine("FAB 하단 여백을 두는 자리가 달라졌다 (#1713).") + appendLine(" 기대: $EXPECTED") + appendLine(" 실제: $actual") + appendLine() + appendLine("줄었다면 그 목록의 마지막 항목이 FAB 뒤로 들어간다.") + appendLine("의도한 변경이면 EXPECTED 를 함께 고칠 것.") + } + } + } + + private companion object { + /** 블록·줄 주석 제거. 형제 가드(`ScanScopeKonsistTest`)와 같은 관용구다. */ + private fun String.withoutComments(): String = replace(BLOCK_COMMENT, " ").replace(LINE_COMMENT, " ") + + val BLOCK_COMMENT = Regex("/[*][\\s\\S]*?[*]/") + val LINE_COMMENT = Regex("//[^\n]*") + + /** `contentPadding = PaddingValues(bottom = AfternoteFabContentBottomPadding)` 한 줄. */ + val CONTENT_PADDING = + Regex("""contentPadding\s*=\s*PaddingValues\(\s*bottom\s*=\s*AfternoteFabContentBottomPadding\s*,?\s*\)""") + + /** 파일별 적용 지점 수. 일기 화면은 목록형·캘린더형 둘이라 2 다. */ + val EXPECTED = + mapOf( + "DiaryScreen.kt" to 2, + "DailyQuestionAnswerListScreen.kt" to 1, + ) + } +}