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 = "카메라를 사용할 수 없습니다."