Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions docs/convention/composable-callback-defaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# 컴포저블 콜백 디폴트 규칙

**컴포저블의 `on*` 콜백 파라미터에 no-op 디폴트(`onXxx: (...) -> Unit = {}`)를 두지 않는다.** 선택적 상호작용은 디폴트가 아니라 타입(nullable 핸들러·오버로드)으로 모델링한다.

`app` · `feature/*` 모듈의 `src/main` 에 적용하고, `konsist` 의 `NoOpCallbackDefaultKonsistTest` 가 강제한다 (#1388).

## 왜 금지인가

no-op 디폴트는 **배선을 빠뜨려도 컴파일이 통과**하게 만든다. 화면은 그려지고, 버튼도 눌리는데, 아무 일도 일어나지 않는다 — 실패가 조용해서 QA 나 사용자 신고로만 드러난다.

실사고 전례가 반복됐다:

- #582 · #618 · #722 — mindrecord 미배선 실사고. 콜백을 넘기지 않은 채 화면이 출고됐다.
- #777 — 137건 전수 대조로 실결함을 적출했지만, **디폴트 자체가 남아 있어 같은 부류가 재발할 통로가 열려 있었다.** 신설 코드가 패턴을 답습하는 것도 확인됐다(PR #1336 의 `onVideoClick`).
- #778 (open) — `TimeLetterWriteScreen.onTextStyleClick` 이 지금도 미배선 의심으로 추적 중이다.

디폴트가 없으면 같은 실수가 **컴파일 에러**로 드러난다. 폴백보다 값 명시·타입으로 강제한다는 #934 방침과 같은 방향이다.

## 처분 기준

### 화면 컴포저블 — 디폴트 전면 제거

실호출부가 NavGraph 한 곳인 화면 컴포저블은 콜백을 전부 required 로 한다. 프리뷰 · screenshotTest · unit test 는 `{}` 를 **명시**한다 (#602 전례).

```kotlin
// Before
fun GalleryDetailScreen(
onBackClick: () -> Unit,
onEditClick: () -> Unit = {}, // 배선 누락이 조용히 no-op
)

// After
fun GalleryDetailScreen(
onBackClick: () -> Unit,
onEditClick: () -> Unit, // 누락 = 컴파일 에러
)

// Preview / screenshotTest
GalleryDetailScreen(onBackClick = {}, onEditClick = {})
```

`{}` 명시는 디폴트와 렌더링이 동일하므로 스크린샷 baseline 이 유지된다. reference PNG 재생성이 필요한 처분이라면 그 처분이 틀린 것이다(골든 정본은 CI 컨테이너 — `docs/testing` 참고).

### 다중 호출부 리프 컴포넌트 — 건별 판정

- **전 호출부가 실값을 넘긴다** → 디폴트만 제거한다. 선택성이 실재하지 않았던 것이다.
- **상호작용이 진짜 선택적이다** → `= {}` 로 "눌러도 아무 일 없는 버튼"을 그리는 대신, 상호작용 UI 자체를 접는다:

```kotlin
// nullable 핸들러 + UI 숨김 (EditDropdownMenu, #1388)
fun EditDropdownMenu(
onDeleteClick: () -> Unit,
onEditClick: (() -> Unit)?, // null = 편집 항목을 그리지 않는다
) {
if (onEditClick != null) {
CustomDropdownItem(text = "수정", onClick = onEditClick)
}
}
```

nullable 핸들러에도 **`= null` 디폴트를 두지 않는다** — 호출부가 "핸들러 있음 / 의도적으로 없음" 을 매번 명시해야 미배선과 의도적 생략이 구분된다. `showEditItem: Boolean` 같은 플래그 + no-op 콜백 짝은 nullable 핸들러 하나로 합친다(플래그와 콜백이 어긋나는 상태를 타입에서 제거).

상호작용 조합이 많아 nullable 이 지저분해지면 오버로드 분리(상호작용 있는 시그니처 / 없는 시그니처)를 쓴다.

### 콜백 홀더 클래스도 같다

`ReceiverHomeActions` 처럼 콜백을 프로퍼티로 묶은 클래스의 `val onXxx: () -> Unit = {}` 도 같은 방식으로 미배선을 숨긴다. 가드가 클래스 주 생성자까지 본다.

## 강제 수단

`konsist/src/test/kotlin/com/afternote/konsist/NoOpCallbackDefaultKonsistTest.kt` 가 app · feature `src/main` 의 `@Composable` 함수 파라미터와 클래스 주 생성자 파라미터를 스캔해, `on`+대문자 이름에 no-op 람다 기본값(`{}` · `{ }` · `{ _ -> }`)이 있으면 실패시킨다.

가드 도입 시점의 잔여 파일은 테스트 안 `LEGACY_NO_OP_DEFAULT_FILES` 에 있다. 목록의 파일은 위반이 있어도 없어도 통과한다(관대 판정 — 청소 PR 과 목록 갱신 PR 의 머지 순서가 develop 을 red 로 만들지 않게). 모듈 담당별 후속 PR(#1388 의 자식 이슈)이 청소하면서 목록에서 빼면 되고, 빼는 걸 잊으면 「해소된 항목은 경고로 알린다」 테스트가 CI 로그에 경고를 남긴다.
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ fun GalleryDetailScreen(
content: GalleryDetailContent = GalleryDetailContent(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
isEditable: Boolean = true,
onEditClick: () -> Unit = {},
onDeleteConfirm: () -> Unit = {},
onEditClick: () -> Unit,
onDeleteConfirm: () -> Unit,
state: AfternoteDetailState = rememberAfternoteDetailState(),
) {
if (isEditable && state.showDeleteDialog) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ fun AccountDetailScreen(
content: AccountDetailContent = AccountDetailContent(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
isEditable: Boolean = true,
onEditClick: () -> Unit = {},
onDeleteConfirm: () -> Unit = {},
onEditClick: () -> Unit,
onDeleteConfirm: () -> Unit,
state: AfternoteDetailState = rememberAfternoteDetailState(),
) {
if (isEditable && state.showDeleteDialog) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ internal fun AfternoteTypeContent(
onPhotoAddClick: () -> Unit,
onVideoAddClick: () -> Unit,
onThumbnailBytesReady: (ByteArray?) -> Unit,
onThumbnailExtractionFailed: (Throwable) -> Unit = {},
onThumbnailExtractionFailed: (Throwable) -> Unit,
) {
when (form.selectedType) {
AfternoteType.MEMORIAL -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ fun MemorialPhotoUpload(
modifier: Modifier = Modifier,
label: String? = null,
displayImageUri: String? = null,
onAddPhotoClick: () -> Unit = {},
onAddPhotoClick: () -> Unit,
) {
val labelText = label ?: stringResource(R.string.afternote_editor_memorial_photo_label)
Column(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ fun MemorialVideoUpload(
videoUrl: String? = null,
thumbnailUrl: String? = null,
onAddVideoClick: () -> Unit,
onThumbnailBytesReady: (ByteArray?) -> Unit = {},
onThumbnailExtractionFailed: (Throwable) -> Unit = {},
onThumbnailBytesReady: (ByteArray?) -> Unit,
onThumbnailExtractionFailed: (Throwable) -> Unit,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
val hasVideo = !videoUrl.isNullOrBlank()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDis
fun MemorialPlaylistEntry(
songs: List<Song>,
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
onNavigateToAddSongScreen: () -> Unit = {},
onClearAllSongs: () -> Unit = {},
onRemoveSongs: (Set<String>) -> Unit = {},
onBackClick: () -> Unit,
onNavigateToAddSongScreen: () -> Unit,
onClearAllSongs: () -> Unit,
onRemoveSongs: (Set<String>) -> Unit,
initialEditMode: Boolean = false,
initialSelectedSongKeys: Set<String> = emptySet(),
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ fun ProcessingMethodCheckbox(
item: ProcessingMethodItem,
expanded: Boolean = false,
isEditing: Boolean = false,
onMoreClick: () -> Unit = {},
onDismissDropdown: () -> Unit = {},
onEditClick: () -> Unit = {},
onDeleteClick: () -> Unit = {},
onEditConfirmed: (String) -> Unit = {},
onMoreClick: () -> Unit,
onDismissDropdown: () -> Unit,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit,
onEditConfirmed: (String) -> Unit,
) {
Row(
modifier = modifier.fillMaxWidth(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ fun AfternoteEditorReceiverList(
onDismissDropdown = {
state.expandedStates[receiver.id] = false
},
showEditItem = false,
onDeleteClick = { onItemDeleteClick(receiver.id) },
)
}
Expand Down Expand Up @@ -101,10 +100,9 @@ private fun AfternoteEditorReceiverItem(
modifier: Modifier = Modifier,
receiver: AfternoteEditorReceiver,
expanded: Boolean = false,
onMoreClick: () -> Unit = {},
onDismissDropdown: () -> Unit = {},
showEditItem: Boolean = true,
onDeleteClick: () -> Unit = {},
onMoreClick: () -> Unit,
onDismissDropdown: () -> Unit,
onDeleteClick: () -> Unit,
) {
Row(
modifier =
Expand Down Expand Up @@ -145,7 +143,8 @@ private fun AfternoteEditorReceiverItem(
expanded = expanded,
onDismissRequest = onDismissDropdown,
onDeleteClick = onDeleteClick,
showEditItem = showEditItem,
// 수신자 행 메뉴엔 편집이 없다 — null 이 편집 항목 자체를 숨긴다.
onEditClick = null,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import com.afternote.feature.afternote.presentation.R
fun AfternoteListItem(
uiModel: ListItemUiModel,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onClick: () -> Unit,
) {
Row(
modifier =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,18 @@ import androidx.compose.ui.window.Popup as ComposePopup
* Material3 `DropdownMenu` 과 달리 `androidx.compose.ui.window.Popup` 기반이라
* 등장 페이드·스케일 애니메이션은 없다. 필요하면 메뉴 `Column` 을
* `androidx.compose.animation.AnimatedVisibility` 로 감싸면 된다.
*
* @param onEditClick 편집 항목 클릭 콜백. **null 이면 편집 항목 자체를 그리지 않는다** — 편집이
* 없는 메뉴(수신자 행)와 "핸들러를 안 넘겨 죽은 편집 버튼이 그려지는" 미배선을 타입으로 구분하기
* 위해 `showEditItem` 플래그 + no-op 디폴트 대신 nullable 핸들러 하나로 모델링한다 (#1388).
*/
@Composable
fun EditDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
onDeleteClick: () -> Unit,
onEditClick: (() -> Unit)?,
modifier: Modifier = Modifier,
onEditClick: () -> Unit = {},
showEditItem: Boolean = true,
popupPositionProvider: PopupPositionProvider = rememberFixedRightPopupPositionProvider(),
) {
if (!expanded) return
Expand All @@ -55,11 +58,13 @@ fun EditDropdownMenu(
properties = PopupProperties(focusable = true),
) {
EditDropdownMenuItems(
showEditItem = showEditItem,
onEditClick = {
onDismissRequest()
onEditClick()
},
onEditClick =
onEditClick?.let { onEdit ->
{
onDismissRequest()
onEdit()
}
},
onDeleteClick = {
onDismissRequest()
onDeleteClick()
Expand All @@ -76,8 +81,7 @@ fun EditDropdownMenu(
*/
@Composable
private fun EditDropdownMenuItems(
showEditItem: Boolean,
onEditClick: () -> Unit,
onEditClick: (() -> Unit)?,
onDeleteClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Expand All @@ -92,7 +96,7 @@ private fun EditDropdownMenuItems(
).background(AfternoteDesign.colors.white)
.width(IntrinsicSize.Max),
) {
if (showEditItem) {
if (onEditClick != null) {
CustomDropdownItem(
text = stringResource(R.string.feature_afternote_menu_edit),
onClick = onEditClick,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ internal fun galleryDetailScreenScreenshot() {
AfternoteTheme {
GalleryDetailScreen(
onBackClick = {},
onEditClick = {},
onDeleteConfirm = {},
content =
GalleryDetailContent(
serviceName = "직접 입력한 갤러리 서비스",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ internal fun accountDetailScreenScreenshot() {
AfternoteTheme {
AccountDetailScreen(
onBackClick = {},
onEditClick = {},
onDeleteConfirm = {},
content =
AccountDetailContent(
serviceName = "직접 입력한 소셜 서비스",
Expand All @@ -30,6 +32,8 @@ internal fun accountDetailBusinessFallbackScreenshot() {
AfternoteTheme {
AccountDetailScreen(
onBackClick = {},
onEditClick = {},
onDeleteConfirm = {},
content =
AccountDetailContent(
serviceName = "직접 입력한 비즈니스 서비스",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ internal fun memorialPhotoUploadEmptyScreenshot() {
modifier = Modifier.padding(16.dp),
label = "추모 사진",
displayImageUri = null,
onAddPhotoClick = {},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ internal fun memorialVideoUploadEmptyScreenshot() {
videoUrl = null,
thumbnailUrl = null,
onAddVideoClick = {},
onThumbnailBytesReady = {},
onThumbnailExtractionFailed = {},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ private fun screenshotSongs(): List<Song> =
@Composable
internal fun memorialPlaylistEntryEmptyScreenshot() {
AfternoteTheme {
MemorialPlaylistEntry(songs = emptyList())
MemorialPlaylistEntry(
songs = emptyList(),
onBackClick = {},
onNavigateToAddSongScreen = {},
onClearAllSongs = {},
onRemoveSongs = {},
)
}
}

Expand All @@ -35,7 +41,13 @@ internal fun memorialPlaylistEntryEmptyScreenshot() {
@Composable
internal fun memorialPlaylistEntryWithSongsScreenshot() {
AfternoteTheme {
MemorialPlaylistEntry(songs = screenshotSongs())
MemorialPlaylistEntry(
songs = screenshotSongs(),
onBackClick = {},
onNavigateToAddSongScreen = {},
onClearAllSongs = {},
onRemoveSongs = {},
)
}
}

Expand All @@ -46,6 +58,10 @@ internal fun memorialPlaylistEntryEditModeScreenshot() {
AfternoteTheme {
MemorialPlaylistEntry(
songs = screenshotSongs(),
onBackClick = {},
onNavigateToAddSongScreen = {},
onClearAllSongs = {},
onRemoveSongs = {},
initialEditMode = true,
)
}
Expand All @@ -58,6 +74,10 @@ internal fun memorialPlaylistEntryEditModeSelectionScreenshot() {
AfternoteTheme {
MemorialPlaylistEntry(
songs = screenshotSongs(),
onBackClick = {},
onNavigateToAddSongScreen = {},
onClearAllSongs = {},
onRemoveSongs = {},
initialEditMode = true,
initialSelectedSongKeys = setOf("screenshot:1"),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ internal fun processingMethodCheckboxScreenshot() {
ProcessingMethodCheckbox(
item = ProcessingMethodItem(localId = 1, text = "보관"),
modifier = Modifier.padding(16.dp),
onMoreClick = {},
onDismissDropdown = {},
onEditClick = {},
onDeleteClick = {},
onEditConfirmed = {},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ private fun AuthorDetailForEdit(
onBackClick = {},
content = account.content,
onEditClick = { onEdit(state.detailId.toString()) },
onDeleteConfirm = {},
)
}
}
Expand Down Expand Up @@ -470,6 +471,7 @@ private fun AuthorDetailForDelete(
onBackClick = {},
content = account.content,
snackbarHostState = snackbarHostState,
onEditClick = {},
onDeleteConfirm = { viewModel.deleteAfternote(state.detailId) },
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ class EditorTouchTargetTest {
ProcessingMethodCheckbox(
item = ProcessingMethodItem(1, "Method"),
onMoreClick = { processingMoreClicks++ },
onDismissDropdown = {},
onEditClick = {},
onDeleteClick = {},
onEditConfirmed = {},
)
AfternoteEditorReceiverList(
afternoteEditReceivers =
Expand Down
Loading
Loading