Skip to content
1 change: 1 addition & 0 deletions feature/setting/presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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` 리소스로 그린다. 프로필 수정 화면과 같은 문장이다. */
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ private fun SettingScreenContent(
SettingProfile(
name = state.name,
email = state.email,
profileImageUrl = state.profileImageUrl,
onInquiryClick = onUnavailableClick,
onNoticeClick = onNoticeClick,
onRecipientListClick = onRecipientListClick,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Expand Down Expand Up @@ -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) }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {},
)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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<String?>(null)
private val requests = Collections.synchronizedList(mutableListOf<Any>())
private val context get() = ApplicationProvider.getApplicationContext<Context>()

@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"
}
}
Loading
Loading