diff --git a/docs/qa/assumptions.md b/docs/qa/assumptions.md index 596c7c2aa1..ef4b1b7da4 100644 --- a/docs/qa/assumptions.md +++ b/docs/qa/assumptions.md @@ -19,6 +19,7 @@ | #739 | #1216 | 인증번호 발송 중 버튼 **문구** («전송 중…») | `receiver_verify_code_requesting` | | #554 | 64cf93888 | 수신인 검색 placeholder **문구** («이름으로 검색하기») | `setting_receiver_search_placeholder`·`timeletter_recipient_search_placeholder` | | #648 | #1346 | 복수 선택 행의 **선택 컨트롤 비주얼** (라디오 시안 → core 원형 체크박스) | `PlaylistSongItem.SongSelectionCheckbox` | +| #490 | #1350 | 서비스 검색 **일치 규칙**과 카탈로그 밖 **기존 custom 값 처리** | `filterEditorServiceOptions` · `EditorFormState.currentServiceOptions` | --- @@ -369,3 +370,23 @@ navigate 대상 한 줄. **뒤집을 때**: `PlaylistSongItem.SongSelectionCheckbox` 하나다. 시안이 선택 컨트롤을 그리면 그 모양으로 바꾸면 되고, semantics(복수 선택)는 게이트 확정이라 유지된다. + +## #490 — 서비스 선택 바텀시트의 검색 규칙과 카탈로그 밖 기존 값 + +**정한 것 1**: 검색은 trim 한 query 의 대소문자 무시 **부분 문자열** 일치로 하고, 결과는 +원본 카탈로그 순서를 유지한다. + +**정한 것 2**: 고정 카탈로그에 없는 기존(legacy) custom 서비스 값은 폼에 보존해 그대로 +표시하되, 선택 후보 목록에는 올리지 않는다. + +**근거가 없는 이유**: 시안은 바텀시트 구성만 보여주고 검색 일치 규칙·정렬 순서를 정의하지 +않는다. 기존 custom 값은 「직접 추가하기」 제거(게이트 확정) 뒤에 남는 데이터라 처리 방식이 +시안·명세 어디에도 없다. + +**판단 근거**: (검색) 짧은 고정 카탈로그라 부분 일치가 관대하고, 재정렬은 시안에 없는 새 +규칙을 만드는 셈이라 원본 순서를 지켰다. (기존 값) 지우면 사용자 데이터 파괴고, 후보로 +노출하면 제거하기로 한 커스텀 플로우가 사실상 부활한다 — 보존+미노출이 양쪽 결정을 존중한다. + +**뒤집을 때**: 검색은 `filterEditorServiceOptions` 함수 하나, 후보 노출은 +`EditorFormState.currentServiceOptions` 다. 기존 값 정리(마이그레이션·삭제)가 확정되면 +보존 동작을 그 정책으로 바꾼다. diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorContent.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorContent.kt index 8e78b9654c..bfa81a0853 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorContent.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorContent.kt @@ -40,6 +40,8 @@ import com.afternote.feature.afternote.presentation.author.editor.processing.mod import com.afternote.feature.afternote.presentation.author.editor.receiver.model.AfternoteEditorReceiverSection import com.afternote.feature.afternote.presentation.author.editor.selection.DropdownMenuStyle import com.afternote.feature.afternote.presentation.author.editor.selection.EditorSelectionDropdown +import com.afternote.feature.afternote.presentation.author.editor.selection.EditorServiceSelectionField +import com.afternote.feature.afternote.presentation.author.editor.selection.EditorServiceSelectionSheet import com.afternote.feature.afternote.presentation.author.editor.state.AfternoteEditorState import com.afternote.feature.afternote.presentation.author.editor.state.AfternoteTypeForm import com.afternote.feature.afternote.presentation.author.editor.state.EditorFormState @@ -88,24 +90,14 @@ internal fun EditorContent( if (form.selectedType.hasServiceSelection) { Spacer(modifier = Modifier.height(20.dp)) - EditorSelectionDropdown( - label = stringResource(R.string.afternote_editor_label_service_name), - selectedValue = form.selectedService.orEmpty(), - options = form.currentServiceOptions, - optionLabel = { it }, - onValueSelected = state::onServiceSelected, - expanded = state.serviceDropdownExpanded, - onExpandedChange = state::onServiceDropdownExpandedChange, + EditorServiceSelectionField( + selectedService = form.selectedService, + onClick = state::openServiceSelectionSheet, placeholder = stringResource( R.string.afternote_editor_service_placeholder, form.selectedType.toDropdownLabel(), ), - menuStyle = - DropdownMenuStyle( - shadowElevation = 10.dp, - tonalElevation = 10.dp, - ), ) } Spacer(modifier = Modifier.height(32.dp)) @@ -158,6 +150,14 @@ fun AfternoteEditorBody( ) MemorialMediaSourceSheet(state = mediaSourceState) + EditorServiceSelectionSheet( + visible = state.isServiceSelectionSheetVisible, + type = form.selectedType, + services = form.currentServiceOptions, + searchQueryState = state.serviceSearchQueryState, + onDismissRequest = state::dismissServiceSelectionSheet, + onServiceSelected = state::onServiceSelected, + ) } /** diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt index f88c6617ce..b23bf97387 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt @@ -28,7 +28,6 @@ import com.afternote.core.ui.popup.PopupType import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.author.editor.processing.CustomServiceDialog import com.afternote.feature.afternote.presentation.author.editor.state.AfternoteEditorState import com.afternote.feature.afternote.presentation.author.editor.state.AfternoteTypeForm import com.afternote.feature.afternote.presentation.author.editor.state.EditorFormState @@ -40,7 +39,7 @@ import com.afternote.feature.afternote.presentation.author.editor.state.remember * 피그마 디자인 기반: * - 헤더 (뒤로가기, 타이틀, 등록 버튼) * - 종류 선택 드롭다운 - * - 서비스명 선택 드롭다운 + * - 검색 가능한 서비스명 선택 바텀시트 * - 계정 정보 입력 (아이디, 비밀번호) * - 계정 처리 방법 선택 (라디오 버튼) * - 처리 방법 리스트 (체크박스) @@ -147,14 +146,6 @@ fun AfternoteEditorScreen( ) { content(snackbarHostState) - if (state.isCustomServiceDialogVisible) { - CustomServiceDialog( - serviceNameState = state.customServiceNameState, - onDismiss = state::dismissCustomServiceDialog, - onAddClick = state::onAddCustomService, - ) - } - if (showExitConfirm) { Popup( type = PopupType.Variant2, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/CustomServiceDialog.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/CustomServiceDialog.kt deleted file mode 100644 index 550c2f0c4f..0000000000 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/CustomServiceDialog.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.afternote.feature.afternote.presentation.author.editor.processing - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.button.AfternoteButton -import com.afternote.core.ui.button.AfternoteButtonType -import com.afternote.core.ui.modifierextention.addFocusCleaner -import com.afternote.core.ui.popup.AfternotePopupCardLayout -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.afternote.presentation.R - -/** - * 직접 입력하기 다이얼로그 컴포넌트 - * - * 피그마 디자인 기반: - * - 타이틀: "직접 입력하기" (18sp, Bold, AfternoteDesign.colors.gray9, 중앙 정렬) - * - 추가 서비스명 입력 필드 (AfternoteDesign.colors.gray1 배경, 8dp radius) - * - 추가하기 버튼 (AfternoteDesign.colors.gray9 배경, 전체 너비) - * - 다이얼로그: 흰색 배경, 16dp radius, drop shadow - */ -@Composable -fun CustomServiceDialog( - serviceNameState: TextFieldState, - onDismiss: () -> Unit, - onAddClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Dialog( - onDismissRequest = onDismiss, - properties = - DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = true, - ), - ) { - CustomServiceDialogContent( - serviceNameState = serviceNameState, - onAddClick = onAddClick, - modifier = modifier, - ) - } -} - -@Composable -private fun CustomServiceDialogContent( - serviceNameState: TextFieldState, - onAddClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val focusManager = LocalFocusManager.current - AfternotePopupCardLayout( - modifier = - modifier - .fillMaxWidth() - .addFocusCleaner(focusManager), - verticalArrangement = Arrangement.spacedBy(34.14.dp), - ) { - // 타이틀 - Text( - text = stringResource(R.string.afternote_editor_custom_service_dialog_title), - style = - AfternoteDesign.typography.bodyLargeB.copy( - color = AfternoteDesign.colors.gray9, - ), - ) - - // 추가 서비스명 입력 필드 - Column( - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - text = stringResource(R.string.afternote_editor_custom_service_name_label), - style = AfternoteDesign.typography.bodySmallR, - color = AfternoteDesign.colors.gray6, - ) - AfternoteTextField( - state = serviceNameState, - ) - } - - // 추가하기 버튼 - AfternoteButton( - text = stringResource(R.string.add_button), - onClick = { - focusManager.clearFocus() - onAddClick() - }, - type = AfternoteButtonType.Default, - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun CustomServiceDialogPreview() { - AfternoteTheme { - CustomServiceDialogContent( - serviceNameState = rememberTextFieldState(), - onAddClick = {}, - ) - } -} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorSelectionDropdown.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorSelectionDropdown.kt index 3a59e1591d..1158e86874 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorSelectionDropdown.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorSelectionDropdown.kt @@ -225,7 +225,7 @@ private fun EditorSelectionDropdownPlaceholderPreview() { listOf( "인스타그램", "페이스북", - "직접 추가하기", + "X", ), optionLabel = { it }, onValueSelected = {}, @@ -257,7 +257,7 @@ private fun EditorSelectionDropdownMenuItemsPreview() { listOf( "인스타그램", "페이스북", - "직접 추가하기", + "X", ), optionLabel = { it }, onSelect = {}, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheet.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheet.kt new file mode 100644 index 0000000000..e5d980d1b7 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheet.kt @@ -0,0 +1,281 @@ +package com.afternote.feature.afternote.presentation.author.editor.selection + +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.TextFieldType +import com.afternote.core.ui.modifierextention.bottomBorder +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.author.editor.EditorSectionLabel +import com.afternote.feature.afternote.presentation.shared.model.AfternoteServiceDisplay + +/** 서비스 선택 시트를 여는 접힌 필드. 기존 선택값이 고정 카탈로그 밖의 값이어도 그대로 표시한다. */ +@Composable +internal fun EditorServiceSelectionField( + selectedService: String?, + placeholder: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + EditorSectionLabel( + text = stringResource(R.string.afternote_editor_label_service_name), + isRequired = false, + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray7, + ) + Row( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable(role = Role.Button, onClick = onClick) + .bottomBorder(color = AfternoteDesign.colors.gray3, width = 0.58.dp) + .padding(top = 4.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val showPlaceholder = selectedService.isNullOrBlank() + Text( + text = if (showPlaceholder) placeholder else selectedService.orEmpty(), + modifier = Modifier.weight(1f), + style = AfternoteDesign.typography.bodyBase, + color = if (showPlaceholder) AfternoteDesign.colors.gray5 else AfternoteDesign.colors.gray8, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + painter = painterResource(R.drawable.feature_afternote_ic_dropdown_vector), + contentDescription = null, + tint = AfternoteDesign.colors.gray8, + ) + } + } +} + +/** + * 고정 서비스 카탈로그를 검색하고 선택하는 공용 에디터 시트. + * + * 닫기·선택 뒤 query 초기화는 호출자가 소유한 [TextFieldState]와 `onDismissRequest` 계약으로 + * 처리한다. 선택 콜백은 정확한 catalog display key를 넘긴다. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EditorServiceSelectionSheet( + visible: Boolean, + type: AfternoteType, + services: List, + searchQueryState: TextFieldState, + onDismissRequest: () -> Unit, + onServiceSelected: (String) -> Unit, +) { + if (!visible) return + val titleRes = type.serviceSelectionSheetTitleResOrNull() ?: return + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + containerColor = AfternoteDesign.colors.white, + dragHandle = null, + ) { + EditorServiceSelectionSheetContent( + title = stringResource(titleRes), + type = type, + services = services, + searchQueryState = searchQueryState, + onServiceSelected = onServiceSelected, + ) + } +} + +/** Popup 윈도 밖에서도 Preview·Robolectric 검증이 가능한 시트 본문. */ +@Composable +internal fun EditorServiceSelectionSheetContent( + title: String, + type: AfternoteType, + services: List, + searchQueryState: TextFieldState, + onServiceSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val filteredServices = + remember(services, searchQueryState.text) { + filterEditorServiceOptions(services, searchQueryState.text) + } + + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(8.dp)) + Box( + modifier = + Modifier + .size(width = 40.dp, height = 4.dp) + .clip(RoundedCornerShape(10.dp)) + .background(AfternoteDesign.colors.gray3), + ) + Spacer(Modifier.height(16.dp)) + Text( + text = title, + style = AfternoteDesign.typography.bodyLargeB, + color = AfternoteDesign.colors.gray9, + ) + Spacer(Modifier.height(20.dp)) + AfternoteTextField( + state = searchQueryState, + type = TextFieldType.Search, + placeholder = stringResource(R.string.afternote_editor_service_search_placeholder), + imeAction = ImeAction.Search, + ) + Spacer(Modifier.height(12.dp)) + + if (filteredServices.isEmpty()) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(112.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.afternote_editor_service_search_empty), + style = AfternoteDesign.typography.bodyBase, + color = AfternoteDesign.colors.gray6, + ) + } + } else { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 480.dp), + ) { + items(filteredServices, key = { it }) { service -> + EditorServiceSelectionRow( + serviceName = service, + type = type, + onClick = { onServiceSelected(service) }, + ) + } + } + } + Spacer(Modifier.height(24.dp)) + } +} + +@Composable +private fun EditorServiceSelectionRow( + serviceName: String, + type: AfternoteType, + onClick: () -> Unit, +) { + val service = remember(serviceName, type) { AfternoteServiceDisplay.fromService(serviceName, type) } + Row( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable(role = Role.Button, onClick = onClick) + .bottomBorder(color = AfternoteDesign.colors.gray3, width = 1.dp) + .padding(horizontal = 4.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Image( + painter = painterResource(service.iconResId), + contentDescription = null, + modifier = + Modifier + .size(32.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop, + ) + Text( + text = service.serviceName, + style = AfternoteDesign.typography.bodyBase, + color = AfternoteDesign.colors.gray9, + ) + } +} + +/** trim한 query의 대소문자를 무시한 부분 문자열 검색. 원본 catalog 순서는 바꾸지 않는다. */ +internal fun filterEditorServiceOptions( + services: List, + rawQuery: CharSequence, +): List { + val query = rawQuery.toString().trim() + return if (query.isEmpty()) { + services + } else { + services.filter { service -> service.contains(query, ignoreCase = true) } + } +} + +@StringRes +internal fun AfternoteType.serviceSelectionSheetTitleResOrNull(): Int? = + when (this) { + AfternoteType.SOCIAL_NETWORK -> R.string.afternote_editor_service_sheet_title_social + AfternoteType.GALLERY_AND_FILES -> R.string.afternote_editor_service_sheet_title_gallery + AfternoteType.BUSINESS -> R.string.afternote_editor_service_sheet_title_business + AfternoteType.MEMORIAL, AfternoteType.ESTATE -> null + } + +@Preview(showBackground = true) +@Composable +private fun EditorServiceSelectionSheetContentPreview() { + AfternoteTheme { + EditorServiceSelectionSheetContent( + title = "소셜 네트워크 서비스 선택", + type = AfternoteType.SOCIAL_NETWORK, + services = listOf("인스타그램", "페이스북", "X"), + searchQueryState = rememberTextFieldState(), + onServiceSelected = {}, + ) + } +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteEditorState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteEditorState.kt index 9ac50198cb..27eced9bd5 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteEditorState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteEditorState.kt @@ -35,7 +35,7 @@ private const val TAG = "AfternoteEditorState" class AfternoteEditorState( val idState: TextFieldState, val passwordState: TextFieldState, - val customServiceNameState: TextFieldState, + val serviceSearchQueryState: TextFieldState, private val getCurrentForm: () -> EditorFormState, private val setType: (AfternoteType) -> Unit, private val setService: (String) -> Unit, @@ -51,15 +51,12 @@ class AfternoteEditorState( val editProcessingMethod: (localId: Int, newText: String) -> Unit, val editorMessages: SnapshotStateList = mutableStateListOf(), ) { - var isCustomServiceDialogVisible by mutableStateOf(false) + var isServiceSelectionSheetVisible by mutableStateOf(false) private set var typeDropdownExpanded by mutableStateOf(false) private set - var serviceDropdownExpanded by mutableStateOf(false) - private set - /** 이벤트 처리에 사용할 최신 폼. 화면 표시는 수집된 `uiState.form`을 사용한다. */ fun currentForm(): EditorFormState = getCurrentForm() @@ -67,33 +64,23 @@ class AfternoteEditorState( typeDropdownExpanded = expanded } - fun onServiceDropdownExpandedChange(expanded: Boolean) { - serviceDropdownExpanded = expanded + fun onTypeSelected(type: AfternoteType) { + dismissServiceSelectionSheet() + setType(type) } - fun onTypeSelected(type: AfternoteType) = setType(type) - - fun onServiceSelected(service: String) { - if (getCurrentForm().isCustomAddOption(service)) { - isCustomServiceDialogVisible = true - } else { - setService(service) - } + fun openServiceSelectionSheet() { + isServiceSelectionSheetVisible = true } - fun dismissCustomServiceDialog() { - isCustomServiceDialogVisible = false - customServiceNameState.edit { replace(0, length, "") } + fun dismissServiceSelectionSheet() { + isServiceSelectionSheetVisible = false + serviceSearchQueryState.edit { replace(0, length, "") } } - fun onAddCustomService() { - val serviceName = - customServiceNameState.text - .toString() - .trim() - if (serviceName.isEmpty()) return - setService(serviceName) - dismissCustomServiceDialog() + fun onServiceSelected(service: String) { + setService(service) + dismissServiceSelectionSheet() } fun addReceiverById( @@ -247,17 +234,17 @@ fun rememberAfternoteEditorState( ): AfternoteEditorState { val idState = rememberTextFieldState() val passwordState = rememberTextFieldState() - val customServiceNameState = rememberTextFieldState() + val serviceSearchQueryState = rememberTextFieldState() val editorMessages = rememberSaveable(saver = editorMessagesSaver) { mutableStateListOf() } - return remember(idState, passwordState, customServiceNameState, editorMessages) { + return remember(idState, passwordState, serviceSearchQueryState, editorMessages) { AfternoteEditorState( idState = idState, passwordState = passwordState, - customServiceNameState = customServiceNameState, + serviceSearchQueryState = serviceSearchQueryState, getCurrentForm = getCurrentForm, setType = setType, setService = setService, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/EditorFormState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/EditorFormState.kt index 48cdae8d5b..612bbea5e9 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/EditorFormState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/EditorFormState.kt @@ -7,8 +7,6 @@ import com.afternote.feature.afternote.presentation.author.editor.receiver.model import com.afternote.feature.afternote.presentation.shared.model.AlbumCover import com.afternote.feature.afternote.presentation.shared.util.AfternoteServiceCatalog -internal const val CUSTOM_ADD_OPTION = "직접 추가하기" - /** * ViewModel이 소유하는 에디터 폼 상태. * @@ -45,16 +43,13 @@ data class EditorFormState( val currentServiceOptions: List get() = when (selectedType) { - AfternoteType.SOCIAL_NETWORK -> AfternoteServiceCatalog.socialServices + CUSTOM_ADD_OPTION + AfternoteType.SOCIAL_NETWORK -> AfternoteServiceCatalog.socialServices AfternoteType.GALLERY_AND_FILES -> AfternoteServiceCatalog.galleryServices - // 비즈니스는 서비스 직접 추가를 지원하지 않는다. AfternoteType.BUSINESS -> AfternoteServiceCatalog.businessServices // 서비스 선택 UI가 없는 유형이다. AfternoteType.MEMORIAL, AfternoteType.ESTATE -> emptyList() } - - fun isCustomAddOption(service: String): Boolean = service == CUSTOM_ADD_OPTION } diff --git a/feature/afternote/presentation/src/main/res/values/strings.xml b/feature/afternote/presentation/src/main/res/values/strings.xml index cdd1a44c45..654edb5f01 100644 --- a/feature/afternote/presentation/src/main/res/values/strings.xml +++ b/feature/afternote/presentation/src/main/res/values/strings.xml @@ -215,8 +215,11 @@ 애프터노트 작성하기 등록 - 직접 입력하기 - 추가 서비스명 + 소셜 네트워크 서비스 선택 + 갤러리 및 파일 서비스 선택 + 비즈니스 서비스 선택 + 서비스 검색하기 + 검색 결과가 없습니다. 추억 플레이리스트 플레이리스트 추가 총 %1$d곡 diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/CustomServiceDialogScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/CustomServiceDialogScreenshotTest.kt deleted file mode 100644 index 9217290c3b..0000000000 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/CustomServiceDialogScreenshotTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.afternote.feature.afternote.presentation.author.editor.processing - -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import com.afternote.core.ui.theme.AfternoteTheme -import com.android.tools.screenshot.PreviewTest - -@PreviewTest -@Preview(showBackground = true) -@Composable -internal fun customServiceDialogScreenshot() { - AfternoteTheme { - CustomServiceDialog( - serviceNameState = rememberTextFieldState(), - onDismiss = {}, - onAddClick = {}, - ) - } -} diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetScreenshotTest.kt new file mode 100644 index 0000000000..8b4c112811 --- /dev/null +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetScreenshotTest.kt @@ -0,0 +1,58 @@ +package com.afternote.feature.afternote.presentation.author.editor.selection + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.shared.util.AfternoteServiceCatalog +import com.android.tools.screenshot.PreviewTest + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun editorServiceSelectionSheetScreenshot() { + AfternoteTheme { + EditorServiceSelectionSheetContent( + title = "소셜 네트워크 서비스 선택", + type = AfternoteType.SOCIAL_NETWORK, + services = AfternoteServiceCatalog.socialServices, + searchQueryState = rememberTextFieldState(), + onServiceSelected = {}, + ) + } +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun editorServiceSelectionSheetEmptySearchScreenshot() { + AfternoteTheme { + EditorServiceSelectionSheetContent( + title = "갤러리 및 파일 서비스 선택", + type = AfternoteType.GALLERY_AND_FILES, + services = AfternoteServiceCatalog.galleryServices, + searchQueryState = rememberTextFieldState("없는 서비스"), + onServiceSelected = {}, + ) + } +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun editorServiceSelectionCustomPrefillFieldScreenshot() { + AfternoteTheme { + Box(modifier = Modifier.padding(20.dp)) { + EditorServiceSelectionField( + selectedService = "사내 레거시 서비스", + placeholder = "소셜네트워크 선택하기", + onClick = {}, + ) + } + } +} diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/processing/CustomServiceDialogScreenshotTestKt/customServiceDialogScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/processing/CustomServiceDialogScreenshotTestKt/customServiceDialogScreenshot_748aa731_0.png deleted file mode 100644 index 12b90ac08d..0000000000 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/processing/CustomServiceDialogScreenshotTestKt/customServiceDialogScreenshot_748aa731_0.png and /dev/null differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionCustomPrefillFieldScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionCustomPrefillFieldScreenshot_748aa731_0.png new file mode 100644 index 0000000000..6cc117a710 Binary files /dev/null and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionCustomPrefillFieldScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetEmptySearchScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetEmptySearchScreenshot_748aa731_0.png new file mode 100644 index 0000000000..4048ce812c Binary files /dev/null and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetEmptySearchScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetScreenshot_748aa731_0.png new file mode 100644 index 0000000000..97da95ed93 Binary files /dev/null and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt index d543511beb..54c40c95e5 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextReplacement import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -151,6 +152,50 @@ class AfternoteAuthorExtendedTest { assertEquals("73", routedItemId) } + @Test + fun serviceSelectionSheet_dismissPreservesCustomPrefillAndSearchSelectsExactCatalogValue() { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.success(authorDetail()) } + } + val viewModel = editorViewModel(repository, itemId = 73L) + var editorState: AfternoteEditorState? = null + + composeRule.setContent { + AfternoteTheme { + AuthorEditorForUpdate( + itemId = 73L, + viewModel = viewModel, + onStateReady = { editorState = it }, + ) + } + } + + composeRule.waitUntil(timeoutMillis = 5_000) { + !viewModel.uiState.value.isPrefillLoading && editorState != null + } + composeRule.onNodeWithText("Instagram").assertIsDisplayed().performClick() + composeRule.onNodeWithText("소셜 네트워크 서비스 선택").assertIsDisplayed() + composeRule.onNodeWithText("서비스 검색하기").performTextInput(" 페이 ") + composeRule.onNodeWithText("페이스북").assertIsDisplayed() + composeRule.onNodeWithText("인스타그램").assertDoesNotExist() + + composeRule.runOnIdle { checkNotNull(editorState).dismissServiceSelectionSheet() } + composeRule.onNodeWithText("소셜 네트워크 서비스 선택").assertDoesNotExist() + assertEquals("Instagram", viewModel.currentForm().selectedService) + assertEquals("", checkNotNull(editorState).serviceSearchQueryState.text.toString()) + + composeRule.onNodeWithText("Instagram").performClick() + composeRule.onNodeWithText("인스타그램").assertIsDisplayed() + composeRule.onNodeWithText("서비스 검색하기").performTextInput("페이") + composeRule.onNodeWithText("페이스북").performClick() + + composeRule.onNodeWithText("소셜 네트워크 서비스 선택").assertDoesNotExist() + composeRule.onNodeWithText("페이스북").assertIsDisplayed() + assertEquals("페이스북", viewModel.currentForm().selectedService) + assertEquals("", checkNotNull(editorState).serviceSearchQueryState.text.toString()) + } + @Test fun repeatedValidation_afterPopupConfirm_showsAgainWithoutSaveCall() { val repository = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorContentSignatureCompletenessTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorContentSignatureCompletenessTest.kt index 3245c6a642..9f57897b21 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorContentSignatureCompletenessTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorContentSignatureCompletenessTest.kt @@ -21,18 +21,15 @@ import org.junit.Test * [AfternoteTypeForm.pristineFor] 의 exhaustive `when` 이 컴파일 단계에서 누락을 잡는다 — 양쪽에 카나리만 둔다. */ class EditorContentSignatureCompletenessTest { - /** - * 다이얼로그 전용 휘발 입력 — dismissCustomServiceDialog 가 비우고 다이얼로그가 자체 back 을 소비하므로 - * 지문에서 제외한다. 여기 올리려면 같은 성질(닫힐 때 소거되는 입력)이어야 한다. - */ - private val dialogTransientStates = - setOf("customServiceNameState") + /** 서비스 시트를 닫거나 선택하면 비우는 검색 query라 이탈 지문에서 제외한다. */ + private val transientUiStates = + setOf("serviceSearchQueryState") private fun newState(): AfternoteEditorState = AfternoteEditorState( idState = TextFieldState(), passwordState = TextFieldState(), - customServiceNameState = TextFieldState(), + serviceSearchQueryState = TextFieldState(), getCurrentForm = { EditorFormState() }, setType = {}, setService = {}, @@ -64,12 +61,12 @@ class EditorContentSignatureCompletenessTest { val before = editorContentSignature(form, state) (getter.invoke(state) as TextFieldState).edit { replace(0, length, "완전성테스트입력") } val after = editorContentSignature(form, state) - if (name in dialogTransientStates) { - assertEquals("다이얼로그 휘발 입력 '$name' 은 지문에서 제외돼야 한다", before, after) + if (name in transientUiStates) { + assertEquals("휘발 UI 입력 '$name' 은 지문에서 제외돼야 한다", before, after) } else { assertNotEquals( "'$name' 입력이 지문에 반영되지 않는다 — editorContentSignature 에 추가하거나 " + - "dialogTransientStates 에 사유와 함께 올릴 것", + "transientUiStates 에 사유와 함께 올릴 것", before, after, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/message/LeaveMessageEditorItemTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/message/LeaveMessageEditorItemTest.kt index 11e591ef1a..c3a4bf552a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/message/LeaveMessageEditorItemTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/message/LeaveMessageEditorItemTest.kt @@ -154,7 +154,7 @@ class LeaveMessageEditorItemTest { AfternoteEditorState( idState = TextFieldState(), passwordState = TextFieldState(), - customServiceNameState = TextFieldState(), + serviceSearchQueryState = TextFieldState(), getCurrentForm = { EditorFormState() }, setType = {}, setService = {}, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetTest.kt new file mode 100644 index 0000000000..cac832609e --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorServiceSelectionSheetTest.kt @@ -0,0 +1,141 @@ +package com.afternote.feature.afternote.presentation.author.editor.selection + +import android.content.Context +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.ui.testing.MinimumTouchTargetSize +import com.afternote.core.ui.testing.assertAccessibleClickTargets +import com.afternote.core.ui.testing.scanEnabledClickTargets +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.shared.util.AfternoteServiceCatalog +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class EditorServiceSelectionSheetTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `검색은 trim과 대소문자 무시 substring을 적용하고 catalog 순서를 유지한다`() { + val services = listOf("Alpha", "beta", "ALPHABET", "gamma") + + assertEquals( + listOf("Alpha", "ALPHABET"), + filterEditorServiceOptions(services, " pHa "), + ) + assertEquals(services, filterEditorServiceOptions(services, " \n ")) + } + + @Test + fun `카테고리별 sheet title은 확정 문구를 사용한다`() { + val context = ApplicationProvider.getApplicationContext() + + assertEquals( + "소셜 네트워크 서비스 선택", + context.getString(requireNotNull(AfternoteType.SOCIAL_NETWORK.serviceSelectionSheetTitleResOrNull())), + ) + assertEquals( + "갤러리 및 파일 서비스 선택", + context.getString(requireNotNull(AfternoteType.GALLERY_AND_FILES.serviceSelectionSheetTitleResOrNull())), + ) + assertEquals( + "비즈니스 서비스 선택", + context.getString(requireNotNull(AfternoteType.BUSINESS.serviceSelectionSheetTitleResOrNull())), + ) + assertNull(AfternoteType.MEMORIAL.serviceSelectionSheetTitleResOrNull()) + assertNull(AfternoteType.ESTATE.serviceSelectionSheetTitleResOrNull()) + } + + @Test + fun `검색 UI는 일치 항목만 남기고 결과가 없으면 빈 결과를 표시한다`() { + val queryState = TextFieldState() + composeRule.setContent { + AfternoteTheme { + EditorServiceSelectionSheetContent( + title = "비즈니스 서비스 선택", + type = AfternoteType.BUSINESS, + services = AfternoteServiceCatalog.businessServices, + searchQueryState = queryState, + onServiceSelected = {}, + ) + } + } + + composeRule.onNodeWithText("서비스 검색하기").performTextInput(" OUT ") + composeRule.onNodeWithText("outlook").assertIsDisplayed() + composeRule.onNodeWithText("네이버 메일").assertDoesNotExist() + + composeRule.onNodeWithText(" OUT ").performTextReplacement("없는 서비스") + composeRule.onNodeWithText("검색 결과가 없습니다.").assertIsDisplayed() + composeRule.onNodeWithText("outlook").assertDoesNotExist() + } + + @Test + fun `서비스 행은 정확한 display key를 넘기는 48dp 이상 Button이다`() { + var selected: String? = null + val queryState = TextFieldState() + composeRule.setContent { + AfternoteTheme { + EditorServiceSelectionSheetContent( + title = "소셜 네트워크 서비스 선택", + type = AfternoteType.SOCIAL_NETWORK, + services = listOf("인스타그램", "페이스북"), + searchQueryState = queryState, + onServiceSelected = { selected = it }, + ) + } + } + + composeRule.assertAccessibleClickTargets() + val rows = + composeRule + .scanEnabledClickTargets() + .filter { it.name == "인스타그램" || it.name == "페이스북" } + assertEquals(2, rows.size) + rows.forEach { row -> + assertEquals(Role.Button, row.role) + assertFalse(row.isSmallerThan(MinimumTouchTargetSize)) + } + + composeRule.onNodeWithText("페이스북").performClick() + assertEquals("페이스북", selected) + } + + @Test + fun `접힌 필드는 catalog 밖 기존 custom 값을 보존하고 sheet 열기 callback을 낸다`() { + var openCalls = 0 + composeRule.setContent { + AfternoteTheme { + EditorServiceSelectionField( + selectedService = "사내 레거시 서비스", + placeholder = "소셜네트워크 선택하기", + onClick = { openCalls += 1 }, + ) + } + } + + composeRule.onNodeWithText("사내 레거시 서비스").assertIsDisplayed().performClick() + + assertEquals(1, openCalls) + val field = composeRule.scanEnabledClickTargets().single { it.name == "사내 레거시 서비스" } + assertEquals(Role.Button, field.role) + assertFalse(field.isSmallerThan(MinimumTouchTargetSize)) + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteEditorServiceSelectionStateTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteEditorServiceSelectionStateTest.kt new file mode 100644 index 0000000000..34c2765bdc --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteEditorServiceSelectionStateTest.kt @@ -0,0 +1,76 @@ +package com.afternote.feature.afternote.presentation.author.editor.state + +import androidx.compose.foundation.text.input.TextFieldState +import com.afternote.feature.afternote.domain.AfternoteType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AfternoteEditorServiceSelectionStateTest { + @Test + fun `선택은 정확한 display key를 저장한 뒤 sheet와 query를 닫는다`() { + var selectedService: String? = null + val state = editorState(setService = { selectedService = it }) + state.openServiceSelectionSheet() + state.serviceSearchQueryState.edit { append(" FACE ") } + + state.onServiceSelected("페이스북") + + assertEquals("페이스북", selectedService) + assertFalse(state.isServiceSelectionSheetVisible) + assertTrue(state.serviceSearchQueryState.text.isEmpty()) + } + + @Test + fun `dismiss는 기존 선택을 바꾸지 않고 query만 초기화한다`() { + var selectedService: String? = null + val state = editorState(setService = { selectedService = it }) + state.openServiceSelectionSheet() + state.serviceSearchQueryState.edit { append("인스타") } + + state.dismissServiceSelectionSheet() + + assertNull(selectedService) + assertFalse(state.isServiceSelectionSheetVisible) + assertTrue(state.serviceSearchQueryState.text.isEmpty()) + } + + @Test + fun `카테고리 변경도 열린 sheet와 query를 정리한 뒤 타입을 전달한다`() { + var selectedType: AfternoteType? = null + val state = editorState(setType = { selectedType = it }) + state.openServiceSelectionSheet() + state.serviceSearchQueryState.edit { append("갤러리") } + + state.onTypeSelected(AfternoteType.GALLERY_AND_FILES) + + assertEquals(AfternoteType.GALLERY_AND_FILES, selectedType) + assertFalse(state.isServiceSelectionSheetVisible) + assertTrue(state.serviceSearchQueryState.text.isEmpty()) + } + + private fun editorState( + setType: (AfternoteType) -> Unit = {}, + setService: (String) -> Unit = {}, + ): AfternoteEditorState = + AfternoteEditorState( + idState = TextFieldState(), + passwordState = TextFieldState(), + serviceSearchQueryState = TextFieldState(), + getCurrentForm = { EditorFormState() }, + setType = setType, + setService = setService, + setMemorialPhoto = {}, + setMemorialVideo = {}, + addReceiverIfAbsent = { _, _, _ -> }, + applyPrefill = {}, + setMemorialThumbnail = {}, + deleteReceiver = {}, + replaceReceiversIfEmpty = {}, + addProcessingMethod = {}, + deleteProcessingMethod = {}, + editProcessingMethod = { _, _ -> }, + ) +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/EditorFormStateServiceCatalogTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/EditorFormStateServiceCatalogTest.kt new file mode 100644 index 0000000000..e6699a4f67 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/EditorFormStateServiceCatalogTest.kt @@ -0,0 +1,43 @@ +package com.afternote.feature.afternote.presentation.author.editor.state + +import com.afternote.feature.afternote.presentation.shared.util.AfternoteServiceCatalog +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class EditorFormStateServiceCatalogTest { + @Test + fun `서비스 선택 3개 카테고리는 고정 catalog 순서를 그대로 제공한다`() { + assertEquals( + AfternoteServiceCatalog.socialServices, + EditorFormState(typeForm = AfternoteTypeForm.Social()).currentServiceOptions, + ) + assertEquals( + AfternoteServiceCatalog.galleryServices, + EditorFormState(typeForm = AfternoteTypeForm.Gallery()).currentServiceOptions, + ) + assertEquals( + AfternoteServiceCatalog.businessServices, + EditorFormState(typeForm = AfternoteTypeForm.Business()).currentServiceOptions, + ) + } + + @Test + fun `어느 catalog에도 직접 추가하기를 노출하지 않는다`() { + val allOptions = + listOf( + EditorFormState(typeForm = AfternoteTypeForm.Social()), + EditorFormState(typeForm = AfternoteTypeForm.Gallery()), + EditorFormState(typeForm = AfternoteTypeForm.Business()), + ).flatMap(EditorFormState::currentServiceOptions) + + assertFalse("직접 추가하기" in allOptions) + } + + @Test + fun `서비스 선택이 없는 카테고리는 빈 catalog다`() { + assertTrue(EditorFormState(typeForm = AfternoteTypeForm.Memorial()).currentServiceOptions.isEmpty()) + assertTrue(EditorFormState(typeForm = AfternoteTypeForm.Estate).currentServiceOptions.isEmpty()) + } +}