diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/ProfileImage.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/ProfileImage.kt index 0b94f82fb3..af49bfbf13 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/ProfileImage.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/ProfileImage.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable 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.unit.Dp @@ -89,6 +90,9 @@ private fun ProfileImageContent( modifier = modifier, placeholder = placeholder, error = placeholder, + // 아바타는 원으로 잘라 쓴다. 기본값(Fit)이면 사용자가 고른 세로·가로 사진이 원 안에서 + // 축소돼 위아래가 비므로, 칸을 채우고 넘치는 쪽을 잘라야 자리가 그대로 유지된다. + contentScale = ContentScale.Crop, ) } else { Image( diff --git a/feature/setting/presentation/build.gradle.kts b/feature/setting/presentation/build.gradle.kts index 1dd95fda5b..c067b5e1c2 100644 --- a/feature/setting/presentation/build.gradle.kts +++ b/feature/setting/presentation/build.gradle.kts @@ -32,7 +32,6 @@ dependencies { implementation(projects.core.ui) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.compose.runtime) - implementation(libs.coil.compose) implementation(libs.kakao.sdk.auth) implementation(libs.kakao.sdk.user) implementation(libs.androidx.biometric) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/ProfilePhotoWithAddBadge.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/ProfilePhotoWithAddBadge.kt deleted file mode 100644 index 143344cf1a..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/ProfilePhotoWithAddBadge.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.afternote.feature.setting.presentation.component - -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.runtime.Composable -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.unit.dp -import coil3.compose.AsyncImage -import com.afternote.feature.setting.presentation.R - -/** - * 프로필 사진 + 우하단 「추가」 배지. - * - * 프로필 이미지 크기 134dp 의 단일 출처다(#1782). setting 의 프로필 편집·수신인 등록 두 화면이 - * 같은 블록을 복제하고 있어 여기로 모았다. - * - * core:ui 의 [ProfileImagePicker][com.afternote.core.ui.ProfileImagePicker] 로 흡수하지 않은 - * 이유는 자산과 배지 기하가 달라서다 — 이쪽은 `ic_default_profile`(60dp 뷰포트) 과 - * 48dp 칸 안에 72dp 로 넘쳐 그려지는 `ic_plus` 를 쓰고, core 쪽은 - * `core_ui_ic_profile_placeholder` 와 48dp `PlusBadgeButton` 을 쓴다. 바꾸면 픽셀이 움직인다. - * 수렴은 시안 대조가 선행돼야 하므로 #2003 으로 분리했다. - * - * 사진을 띄우는 갈래에서도 대기·실패 표시를 `ic_default_profile` 로 두는 것은 같은 이유다 — - * core placeholder 로 떨어뜨리면 로딩 한 프레임 동안 다른 자산이 보인다. - * - * @param onAddClick 배지를 눌렀을 때. `null` 이면 배지가 눌리지 않는다 — 사진 선택 경로가 없는 - * 표시 전용 화면(수신인 등록)과 저장 중이라 선택을 막아야 하는 순간이 그 자리다. 「눌러도 - * 아무 일 없는 배지」를 그리지 않도록 디폴트를 두지 않는다(`docs/convention/composable-callback-defaults.md`). - * @param displayImageUri 아바타에 그릴 이미지. 로컬 content URI 든 서버 URL 이든 같다. - * 비어 있으면 기본 아바타를 그린다. - */ -@Composable -internal fun ProfilePhotoWithAddBadge( - onAddClick: (() -> Unit)?, - modifier: Modifier = Modifier, - displayImageUri: String? = null, -) { - Box(modifier = modifier.size(134.dp)) { - val defaultProfile = painterResource(R.drawable.ic_default_profile) - if (displayImageUri.isNullOrBlank()) { - Image( - painter = defaultProfile, - contentDescription = "기본", - modifier = Modifier.fillMaxSize(), - ) - } else { - AsyncImage( - model = displayImageUri, - contentDescription = "프로필 사진", - // 기본 아바타가 뷰포트를 꽉 채운 원이라, 사진도 같은 원으로 잘라야 배지 위치가 그대로 맞는다. - modifier = - Modifier - .fillMaxSize() - .clip(CircleShape), - contentScale = ContentScale.Crop, - placeholder = defaultProfile, - error = defaultProfile, - ) - } - Box( - modifier = - Modifier - .align(Alignment.BottomEnd) - .size(48.dp) - .then(if (onAddClick == null) Modifier else Modifier.clickable(onClick = onAddClick)), - contentAlignment = Alignment.Center, - ) { - Image( - painter = painterResource(R.drawable.ic_plus), - contentDescription = "추가", - modifier = Modifier.requiredSize(72.dp), - ) - } - } -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ProfileEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ProfileEditScreen.kt index 2685c4298b..43d6789004 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ProfileEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ProfileEditScreen.kt @@ -38,6 +38,8 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.ProfileImage +import com.afternote.core.ui.ProfileImagePicker import com.afternote.core.ui.button.AfternoteButton import com.afternote.core.ui.button.AfternoteButtonType import com.afternote.core.ui.mvi.ObserveSignal @@ -45,7 +47,6 @@ import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.component.ProfilePhotoSourceSheet -import com.afternote.feature.setting.presentation.component.ProfilePhotoWithAddBadge import com.afternote.feature.setting.presentation.component.SettingLoadErrorContent import com.afternote.feature.setting.presentation.component.rememberProfilePhotoSourceState import com.afternote.feature.setting.presentation.viewmodel.ProfileEditEvent @@ -196,12 +197,18 @@ private fun ProfileEditForm( .padding(top = 50.dp), contentAlignment = Alignment.Center, ) { - ProfilePhotoWithAddBadge( - // 저장 중에는 배지도 잠근다 — 업로드는 시작 시점의 URI 로 진행되므로, 그 사이 - // 다른 사진을 고르게 두면 보이는 사진과 올라가는 사진이 갈린다. - onAddClick = if (state.isUpdating) null else onAddPhotoClick, - displayImageUri = state.displayImageUri, - ) + // 저장 중에는 배지를 걷는다 — 업로드는 시작 시점의 URI 로 진행되므로, 그 사이 + // 다른 사진을 고르게 두면 보이는 사진과 올라가는 사진이 갈린다. 잠근 배지를 그려 + // 두면 눌러도 아무 일 없는 버튼이 되므로 상호작용 자체를 접는다 + // (`docs/convention/composable-callback-defaults.md`). + if (state.isUpdating) { + ProfileImage(displayImageUri = state.displayImageUri) + } else { + ProfileImagePicker( + onPickClick = onAddPhotoClick, + displayImageUri = state.displayImageUri, + ) + } } } item { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt index e9d9620fc4..46ebfbfae7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt @@ -40,6 +40,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteTextField import com.afternote.core.ui.PhoneNumberInputTransformation import com.afternote.core.ui.PhoneNumberVisualTransformation +import com.afternote.core.ui.ProfileImage import com.afternote.core.ui.UiText import com.afternote.core.ui.asString import com.afternote.core.ui.mvi.ObserveSignal @@ -47,7 +48,6 @@ import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.component.ProfilePhotoWithAddBadge import com.afternote.feature.setting.presentation.viewmodel.ReceiverPhoneValidation import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterIntent import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterViewModel @@ -178,8 +178,11 @@ internal fun ReceiverRegisterContent( horizontalAlignment = Alignment.CenterHorizontally, ) { item { - // 수신인 등록에는 사진 선택 경로가 없다 — 표시 전용이라는 것을 널로 명시한다. - ProfilePhotoWithAddBadge(onAddClick = null) + // 수신인 등록에는 사진 선택 경로가 아직 없다. 시안(4327:64519)에는 「추가」 배지가 + // 있지만, 선택을 붙이는 배선이 들어올 때 core 의 ProfileImagePicker 로 같이 들어온다. + // 그때까지 눌러도 아무 일 없는 배지를 그리는 대신 표시 전용 아바타만 둔다 + // (docs/convention/composable-callback-defaults.md). + ProfileImage() } item { Spacer(modifier = Modifier.height(56.dp)) diff --git a/feature/setting/presentation/src/main/res/drawable/ic_plus.xml b/feature/setting/presentation/src/main/res/drawable/ic_plus.xml deleted file mode 100644 index 8b86a49692..0000000000 --- a/feature/setting/presentation/src/main/res/drawable/ic_plus.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverEditScreenScreenshotTestKt/receiverEditScreenCompactScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverEditScreenScreenshotTestKt/receiverEditScreenCompactScreenshot_c2abd7e6_0.png index 4d2e28a0e6..9605903624 100644 Binary files a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverEditScreenScreenshotTestKt/receiverEditScreenCompactScreenshot_c2abd7e6_0.png and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverEditScreenScreenshotTestKt/receiverEditScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverEditScreenScreenshotTestKt/receiverEditScreenScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverEditScreenScreenshotTestKt/receiverEditScreenScreenshot_748aa731_0.png index e726e4440f..212245e922 100644 Binary files a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverEditScreenScreenshotTestKt/receiverEditScreenScreenshot_748aa731_0.png and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverEditScreenScreenshotTestKt/receiverEditScreenScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfilePhotoCaptureTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfilePhotoCaptureTest.kt index b47a4a86e4..a3900bc757 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfilePhotoCaptureTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfilePhotoCaptureTest.kt @@ -77,7 +77,7 @@ class ProfilePhotoCaptureTest { assertNotNull(captured) assertEquals(registry.lastCaptureTarget?.toString(), captured) assertEquals(captured, viewModel.success().displayImageUri) - composeRule.onNodeWithContentDescription(PROFILE_PHOTO).assertIsDisplayed() + composeRule.onNodeWithContentDescription(PROFILE_IMAGE).assertIsDisplayed() // (2) 매니페스트 authority · setting_file_paths.xml 의 cache-path · 코드 상수가 서로 맞는가. // 어긋나면 IllegalArgumentException 인데 그 지점이 카메라를 띄우려는 순간이라 드러나는 시점이 늦다. diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfilePhotoPickerTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfilePhotoPickerTest.kt index a0b32a7b96..9e457cc4c9 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfilePhotoPickerTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfilePhotoPickerTest.kt @@ -5,7 +5,6 @@ import androidx.activity.compose.LocalActivityResultRegistryOwner import androidx.activity.result.ActivityResultRegistryOwner import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.assertHasClickAction -import androidx.compose.ui.test.assertHasNoClickAction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.hasText @@ -35,7 +34,7 @@ import org.robolectric.annotation.Config private const val SELECTED_URI = "content://gallery/picked" /** - * 프로필 편집 화면의 「추가」 배지 → 소스 시트 → 갤러리 경로 (#1438). + * 프로필 편집 화면의 편집 배지 → 소스 시트 → 갤러리 경로 (#1438). * * 피커 자체는 띄우지 않는다 — [LocalActivityResultRegistryOwner] 를 [FakeMediaResultRegistry] 로 갈아 * 끼우고, 화면의 공개 계약(배지 탭 → 시트 → 상태·아바타)만 본다. 온보딩의 @@ -65,7 +64,7 @@ class ProfilePhotoPickerTest { assertNull(viewModel.success().selectedImageUri) assertEquals(SERVER_IMAGE_URL, viewModel.success().displayImageUri) } - composeRule.onNodeWithContentDescription(PROFILE_PHOTO).assertIsDisplayed() + composeRule.onNodeWithContentDescription(PROFILE_IMAGE).assertIsDisplayed() } @Test @@ -74,7 +73,9 @@ class ProfilePhotoPickerTest { val viewModel = viewModel(user(profileImageUrl = null)) setContent(viewModel, registry) - composeRule.onNodeWithContentDescription(DEFAULT_PHOTO).assertIsDisplayed() + composeRule.runOnIdle { assertNull(viewModel.success().displayImageUri) } + composeRule.onNodeWithContentDescription(PROFILE_IMAGE).assertIsDisplayed() + composeRule.openPhotoSourceSheet() composeRule.onNodeWithText(GALLERY_ITEM).performClick() composeRule.awaitPhotoSourceSheetClosed() @@ -82,8 +83,11 @@ class ProfilePhotoPickerTest { composeRule.runOnIdle { assertEquals(1, registry.galleryLaunches) assertEquals(SELECTED_URI, viewModel.success().selectedImageUri) + // 아바타가 그리는 값 자체를 본다 — 기본 아바타와 고른 사진은 같은 설명을 쓰므로 + // 노드 조회로는 갈리지 않는다. + assertEquals(SELECTED_URI, viewModel.success().displayImageUri) } - composeRule.onNodeWithContentDescription(PROFILE_PHOTO).assertIsDisplayed() + composeRule.onNodeWithContentDescription(PROFILE_IMAGE).assertIsDisplayed() } @Test @@ -114,7 +118,7 @@ class ProfilePhotoPickerTest { } @Test - fun `저장 중에는 배지에 클릭 동작이 없어 시트도 피커도 열리지 않는다`() { + fun `저장 중에는 배지가 그려지지 않아 시트도 피커도 열리지 않는다`() { val pendingUpdate = CompletableDeferred() val registry = FakeMediaResultRegistry().apply { galleryResult = Uri.parse(SELECTED_URI) } val repository = @@ -124,16 +128,16 @@ class ProfilePhotoPickerTest { } val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) setContent(viewModel, registry) - // 저장 전에는 눌린다 — 이 대조가 없으면 아래 단언이 「원래 못 누르는 것」도 통과시킨다. + // 저장 전에는 눌린다 — 이 대조가 없으면 아래 단언이 「원래 없는 배지」도 통과시킨다. composeRule.onNodeWithContentDescription(ADD_BADGE).assertHasClickAction() composeRule.runOnIdle { viewModel.onIntent(ProfileEditIntent.UpdateProfile("새 이름", "01012345678")) } composeRule.waitUntil(timeoutMillis = PROFILE_TEST_TIMEOUT_MILLIS) { viewModel.success().isUpdating } composeRule.waitForIdle() - composeRule.onNodeWithContentDescription(ADD_BADGE).assertHasNoClickAction() - composeRule.onNodeWithContentDescription(ADD_BADGE).performClick() - composeRule.waitForIdle() + // 아바타는 남고 배지만 사라진다 — 누를 것이 없으니 시트를 여는 입구도 없다. + composeRule.onNodeWithContentDescription(PROFILE_IMAGE).assertIsDisplayed() + composeRule.onNodeWithContentDescription(ADD_BADGE).assertDoesNotExist() // 시트가 열리지 않았고, 어느 런처도 뜨지 않았고, 선택도 그대로다. composeRule.awaitPhotoSourceSheetClosed() diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfilePhotoTestSupport.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfilePhotoTestSupport.kt index e9816f8a83..0b77bee6d4 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfilePhotoTestSupport.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfilePhotoTestSupport.kt @@ -9,11 +9,22 @@ import com.afternote.feature.setting.presentation.viewmodel.ProfileEditViewModel internal const val PROFILE_TEST_TIMEOUT_MILLIS = 5_000L -/** `ProfilePhotoWithAddBadge` 의 「추가」 배지 — 눌리는 상태에서는 clickable 이 자손 semantics 를 합친다. */ -internal const val ADD_BADGE = "추가" +/** + * core 의 `ProfileImagePicker` 가 그리는 편집 배지 — `core_ui_content_description_profile_edit`. + * + * 사진 선택을 받을 수 없는 상태에서는 배지가 아예 그려지지 않으므로, 잠김 판정은 클릭 동작이 아니라 + * 노드 부재로 한다. + */ +internal const val ADD_BADGE = "프로필 수정 버튼" + +/** + * 아바타 — `core_ui_content_description_profile_image`. + * + * 기본 아바타와 고른 사진이 같은 설명을 쓴다. 「사진이 실렸는가」는 이 노드로 갈리지 않으므로 + * `displayImageUri` 로 단언한다. + */ +internal const val PROFILE_IMAGE = "프로필 이미지" -internal const val DEFAULT_PHOTO = "기본" -internal const val PROFILE_PHOTO = "프로필 사진" internal const val GALLERY_ITEM = "갤러리에서 선택" internal const val CAMERA_ITEM = "사진 촬영" internal const val CAPTURE_UNAVAILABLE = "카메라를 사용할 수 없습니다."