diff --git a/feature/setting/presentation/build.gradle.kts b/feature/setting/presentation/build.gradle.kts index 1f42cfe4c..8afe3fee0 100644 --- a/feature/setting/presentation/build.gradle.kts +++ b/feature/setting/presentation/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { implementation(projects.core.model) implementation(projects.core.ui) implementation(libs.androidx.compose.runtime) + implementation(libs.coil.compose) implementation(libs.androidx.biometric) implementation(libs.androidx.credentials) implementation(libs.androidx.credentials.play.services.auth) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt index 99198174b..013ea3672 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt @@ -16,6 +16,7 @@ internal sealed interface SettingProfileState { data class Success( val name: String, val email: String, + val profileImageUrl: String? = null, ) : SettingProfileState /** 문구는 화면이 `setting_profile_load_error` 리소스로 그린다. 프로필 수정 화면과 같은 문장이다. */ @@ -50,6 +51,7 @@ internal sealed interface SettingReducerEvent : ReducerEvent { data class ProfileLoaded( val name: String, val email: String, + val profileImageUrl: String?, ) : SettingReducerEvent data object ProfileFailed : SettingReducerEvent diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt index 9178b05da..aece551f9 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt @@ -166,6 +166,7 @@ private fun SettingScreenContent( SettingProfile( name = state.name, email = state.email, + profileImageUrl = state.profileImageUrl, onInquiryClick = onUnavailableClick, onNoticeClick = onNoticeClick, onRecipientListClick = onRecipientListClick, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt index 64652613b..4b795f2a2 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt @@ -46,7 +46,7 @@ internal class SettingViewModel } is SettingReducerEvent.ProfileLoaded -> { - state.copy(profile = SettingProfileState.Success(event.name, event.email)) + state.copy(profile = SettingProfileState.Success(event.name, event.email, event.profileImageUrl)) } SettingReducerEvent.ProfileFailed -> { @@ -90,7 +90,7 @@ internal class SettingViewModel viewModelScope.launch { dispatch(SettingReducerEvent.ProfileLoading) runCatchingCancellable { myProfileRepository.getMyProfile() } - .onSuccess { dispatch(SettingReducerEvent.ProfileLoaded(it.name, it.email)) } + .onSuccess { dispatch(SettingReducerEvent.ProfileLoaded(it.name, it.email, it.profileImageUrl)) } .onFailure { dispatch(SettingReducerEvent.ProfileFailed) } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingProfile.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingProfile.kt index 1dc7c1428..bd17a9293 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingProfile.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingProfile.kt @@ -10,20 +10,26 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text 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 +import coil3.compose.AsyncImage import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.setting.presentation.R +import com.afternote.core.ui.R as CoreUiR @Composable internal fun SettingProfile( name: String, email: String, + profileImageUrl: String?, onInquiryClick: () -> Unit, onNoticeClick: () -> Unit, onRecipientListClick: () -> Unit, @@ -43,11 +49,24 @@ internal fun SettingProfile( .padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically, ) { - Image( - painterResource(R.drawable.setting_ic_default_profile), - contentDescription = "기본 이미지", - modifier = Modifier.size(60.dp), - ) + val defaultProfile = painterResource(R.drawable.setting_ic_default_profile) + val profileDescription = stringResource(CoreUiR.string.core_ui_content_description_profile_image) + if (profileImageUrl.isNullOrBlank()) { + Image( + painter = defaultProfile, + contentDescription = profileDescription, + modifier = Modifier.size(60.dp).clip(CircleShape), + ) + } else { + AsyncImage( + model = profileImageUrl, + contentDescription = profileDescription, + modifier = Modifier.size(60.dp).clip(CircleShape), + placeholder = defaultProfile, + error = defaultProfile, + contentScale = ContentScale.Crop, + ) + } Spacer(modifier = Modifier.padding(12.dp)) Column { Text( diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTest.kt new file mode 100644 index 000000000..ea8aa9fc2 --- /dev/null +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTest.kt @@ -0,0 +1,66 @@ +package com.afternote.feature.setting.presentation.home + +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.tooling.preview.Preview +import coil3.annotation.ExperimentalCoilApi +import coil3.asImage +import coil3.compose.AsyncImagePainter +import coil3.compose.AsyncImagePreviewHandler +import coil3.compose.LocalAsyncImagePreviewHandler +import coil3.request.ErrorResult +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.home.component.SettingProfile +import com.android.tools.screenshot.PreviewTest +import java.io.IOException + +@PreviewTest +@Preview(showBackground = true, widthDp = 360) +@Composable +internal fun settingProfileDefaultScreenshot() { + SettingProfileScreenshotContent(profileImageUrl = null) +} + +@OptIn(ExperimentalCoilApi::class) +@PreviewTest +@Preview(showBackground = true, widthDp = 360) +@Composable +internal fun settingProfilePhotoScreenshot() { + val photo = + Bitmap + .createBitmap(160, 120, Bitmap.Config.ARGB_8888) + .apply { eraseColor(0xFF5B7DB1.toInt()) } + .asImage() + CompositionLocalProvider(LocalAsyncImagePreviewHandler provides AsyncImagePreviewHandler { photo }) { + SettingProfileScreenshotContent(profileImageUrl = "content://preview/profile-photo") + } +} + +@OptIn(ExperimentalCoilApi::class) +@PreviewTest +@Preview(showBackground = true, widthDp = 360) +@Composable +internal fun settingProfilePhotoFailureScreenshot() { + val failedRequest = + AsyncImagePreviewHandler { _, request -> + AsyncImagePainter.State.Error(null, ErrorResult(null, request, IOException("missing image"))) + } + CompositionLocalProvider(LocalAsyncImagePreviewHandler provides failedRequest) { + SettingProfileScreenshotContent(profileImageUrl = "content://preview/missing-photo") + } +} + +@Composable +private fun SettingProfileScreenshotContent(profileImageUrl: String?) { + AfternoteTheme { + SettingProfile( + name = "홍길동", + email = "user@example.com", + profileImageUrl = profileImageUrl, + onInquiryClick = {}, + onNoticeClick = {}, + onRecipientListClick = {}, + ) + } +} diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfileDefaultScreenshot_74131fac_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfileDefaultScreenshot_74131fac_0.png new file mode 100644 index 000000000..a0942fdfd Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfileDefaultScreenshot_74131fac_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfilePhotoFailureScreenshot_74131fac_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfilePhotoFailureScreenshot_74131fac_0.png new file mode 100644 index 000000000..a0942fdfd Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfilePhotoFailureScreenshot_74131fac_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfilePhotoScreenshot_74131fac_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfilePhotoScreenshot_74131fac_0.png new file mode 100644 index 000000000..bd8e9805d Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfilePhotoScreenshot_74131fac_0.png differ diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileAvatarTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileAvatarTest.kt new file mode 100644 index 000000000..128c9f628 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileAvatarTest.kt @@ -0,0 +1,161 @@ +package com.afternote.feature.setting.presentation.home + +import android.app.Application +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.view.View +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.test.core.app.ApplicationProvider +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.annotation.DelicateCoilApi +import coil3.asImage +import coil3.intercept.Interceptor +import coil3.request.ErrorResult +import coil3.request.SuccessResult +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.home.component.SettingProfile +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.IOException +import java.util.Collections +import kotlin.math.sqrt +import com.afternote.core.ui.R as CoreUiR + +@OptIn(DelicateCoilApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], application = Application::class) +class SettingProfileAvatarTest { + @get:Rule + val composeRule = createComposeRule() + + private lateinit var imageLoader: ImageLoader + private lateinit var hostView: View + private var imageUrl by mutableStateOf(null) + private val requests = Collections.synchronizedList(mutableListOf()) + private val context get() = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + imageLoader = + ImageLoader + .Builder(context) + .components { + add( + Interceptor { chain -> + requests += chain.request.data + if (chain.request.data == BROKEN) { + ErrorResult(null, chain.request, IOException("missing image")) + } else { + val bitmap = Bitmap.createBitmap(60, 60, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(if (chain.request.data == FIRST) Color.RED else Color.GREEN) + SuccessResult(bitmap.asImage(), chain.request) + } + }, + ) + }.build() + SingletonImageLoader.setUnsafe(imageLoader) + composeRule.setContent { + hostView = LocalView.current + AfternoteTheme { + SettingProfile( + name = "name", + email = "user@example.com", + profileImageUrl = imageUrl, + onInquiryClick = {}, + onNoticeClick = {}, + onRecipientListClick = {}, + ) + } + } + } + + @After + fun tearDown() { + SingletonImageLoader.reset() + imageLoader.shutdown() + } + + @Test + fun `등록 사진을 그리고 다른 URL로 바뀌면 새 사진을 그린다`() { + show(FIRST) + waitForColor(Color.RED) + show(SECOND) + waitForColor(Color.GREEN) + assertEquals(listOf(FIRST, SECOND), requests) + } + + @Test + fun `사진 삭제와 빈 URL은 기본 이미지로 돌아가고 새 이미지 요청을 하지 않는다`() { + val defaultPixels = avatarPixels() + show(FIRST) + waitForColor(Color.RED) + + show(null) + assertArrayEquals(defaultPixels, avatarPixels()) + show(" ") + assertArrayEquals(defaultPixels, avatarPixels()) + assertEquals(listOf(FIRST), requests) + } + + @Test + fun `새 사진 로드 실패는 이전 사진 대신 기본 이미지를 그린다`() { + val defaultPixels = avatarPixels() + show(FIRST) + waitForColor(Color.RED) + + show(BROKEN) + composeRule.waitUntil(5_000) { requests.contains(BROKEN) } + composeRule.waitUntil(5_000) { avatarPixels().contentEquals(defaultPixels) } + assertArrayEquals(defaultPixels, avatarPixels()) + } + + private fun show(url: String?) { + composeRule.runOnIdle { imageUrl = url } + composeRule.waitForIdle() + } + + private fun waitForColor(color: Int) { + composeRule.waitUntil(5_000) { + val pixels = avatarPixels() + val width = sqrt(pixels.size.toDouble()).toInt() + pixels[pixels.size / 2 + width / 2] == color + } + } + + private fun avatarPixels(): IntArray { + val description = context.getString(CoreUiR.string.core_ui_content_description_profile_image) + val bounds = composeRule.onNodeWithContentDescription(description).fetchSemanticsNode().boundsInRoot + return composeRule.runOnIdle { + val bitmap = Bitmap.createBitmap(hostView.width, hostView.height, Bitmap.Config.ARGB_8888) + hostView.draw(Canvas(bitmap)) + val width = bounds.width.toInt() + val height = bounds.height.toInt() + IntArray(width * height).also { + bitmap.getPixels(it, 0, width, bounds.left.toInt(), bounds.top.toInt(), width, height) + } + } + } + + private companion object { + const val FIRST = "https://images.example/first.jpg" + const val SECOND = "https://images.example/second.jpg" + const val BROKEN = "https://images.example/missing.jpg" + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileImageTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileImageTest.kt new file mode 100644 index 000000000..ec886de0f --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileImageTest.kt @@ -0,0 +1,87 @@ +package com.afternote.feature.setting.presentation.home + +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.model.user.User +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingProfileImageTest { + private val dispatcher = StandardTestDispatcher() + private val repository = FakeMyProfileRepository.strict() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `프로필 조회와 갱신 결과의 사진 URL을 홈 상태에 전달한다`() = + runTest(dispatcher) { + var imageUrl = "https://images.example/first.jpg" + repository.onGetMyProfile = { User("name", "user@example.com", null, imageUrl) } + val viewModel = viewModel() + runCurrent() + assertEquals(imageUrl, viewModel.profile().profileImageUrl) + + imageUrl = "https://images.example/second.jpg" + viewModel.onIntent(SettingIntent.Refresh) + runCurrent() + assertEquals(imageUrl, viewModel.profile().profileImageUrl) + } + + @Test + fun `갱신 결과에 사진이 없으면 이전 URL을 남기지 않는다`() = + runTest(dispatcher) { + var imageUrl: String? = "https://images.example/first.jpg" + repository.onGetMyProfile = { User("name", "user@example.com", null, imageUrl) } + val viewModel = viewModel() + runCurrent() + assertEquals(imageUrl, viewModel.profile().profileImageUrl) + + imageUrl = null + viewModel.onIntent(SettingIntent.Refresh) + runCurrent() + assertNull(viewModel.profile().profileImageUrl) + } + + @Test + fun `프로필 갱신 실패 뒤에는 이전 사진을 담은 성공 상태가 남지 않는다`() = + runTest(dispatcher) { + repository.onGetMyProfile = { User("name", "user@example.com", null, "https://images.example/first.jpg") } + val viewModel = viewModel() + runCurrent() + + repository.onGetMyProfile = { throw IOException("offline") } + viewModel.onIntent(SettingIntent.Refresh) + runCurrent() + assertEquals(SettingProfileState.Error, viewModel.uiState.value.profile) + } + + private fun viewModel() = + SettingViewModel( + authRepository = FakeAuthRepository.strict(), + myProfileRepository = repository, + accountRepository = FakeSettingAccountRepository.strict(), + ) + + private fun SettingViewModel.profile() = uiState.value.profile as SettingProfileState.Success +}