feat(chat-room): Coil 기반 프로필 이미지 로딩 유틸 도입 및 적용

채팅방의 프로필 이미지 로딩을 공용 유틸(loadProfileImage)로 통일하고
플레이스홀더/에러 처리 및 둥근 모서리 변환을 기본 적용했습니다.

- ImageLoader.kt 추가: loadProfileImage(ImageView, url, cornerRadiusDp)
- ChatMessageAdapter: AI 프로필 이미지 로딩에 유틸 적용
- ChatRoomActivity: 헤더 프로필 이미지 로딩에 유틸 적용 (배경 이미지는 기존 유지)
This commit is contained in:
2025-08-14 00:04:48 +09:00
parent 7451fccff9
commit d3a64d8359
3 changed files with 101 additions and 54 deletions

View File

@@ -16,7 +16,6 @@ import android.widget.TextView
import androidx.annotation.LayoutRes
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import coil.load
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemChatAiMessageBinding
import kr.co.vividnext.sodalive.databinding.ItemChatTypingIndicatorBinding
@@ -283,12 +282,9 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
binding.tvName.text = displayName ?: ""
}
// 프로필 이미지 로딩 (Coil)
// 프로필 이미지 로딩 (공용 유틸 + 둥근 모서리 적용)
if (binding.ivProfile.isVisible) {
binding.ivProfile.load(data.profileImageUrl) {
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
}
loadProfileImage(binding.ivProfile, data.profileImageUrl)
}
// 그룹 내부 간격 최소화 (상단 패딩 축소)

View File

@@ -93,11 +93,8 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
binding.tvName.text = info.name
binding.tvName.isVisible = info.name.isNotBlank()
// 프로필 이미지 (Coil)
binding.ivProfile.load(info.profileImageUrl) {
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
}
// 프로필 이미지 (공용 유틸 + 둥근 모서리 적용)
loadProfileImage(binding.ivProfile, info.profileImageUrl)
// 배경 프로필 이미지 (5.5)
binding.ivBackgroundProfile.load(info.profileImageUrl) {
placeholder(R.drawable.ic_placeholder_profile)
@@ -389,7 +386,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
compositeDisposable.add(localDisposable)
// 2) 서버 통합 API로 동기화 및 UI 갱신
val token = "Bearer ${kr.co.vividnext.sodalive.common.SharedPreferenceManager.token}"
val token = "Bearer ${SharedPreferenceManager.token}"
val networkDisposable = chatRepository.enterChatRoom(token = token, roomId = roomId)
.observeOn(io.reactivex.rxjava3.android.schedulers.AndroidSchedulers.mainThread())
.subscribe({ response ->
@@ -491,53 +488,54 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
}
val cursor: Long? = nextCursor ?: fallbackOldestCreatedAt
val disposable = chatRepository.loadMoreMessages(token = token, roomId = roomId, cursor = cursor)
.observeOn(io.reactivex.rxjava3.android.schedulers.AndroidSchedulers.mainThread())
.subscribe({ response ->
// 서버에서 받은 메시지(이전 것들)를 오래된 -> 최신 순으로 정렬
val sorted = response.messages.sortedBy { it.createdAt }
val disposable =
chatRepository.loadMoreMessages(token = token, roomId = roomId, cursor = cursor)
.observeOn(io.reactivex.rxjava3.android.schedulers.AndroidSchedulers.mainThread())
.subscribe({ response ->
// 서버에서 받은 메시지(이전 것들)를 오래된 -> 최신 순으로 정렬
val sorted = response.messages.sortedBy { it.createdAt }
// 중복 제거: 기존 목록의 messageId 집합과 비교
val existingIds: Set<Long> = items.mapNotNull {
when (it) {
is ChatListItem.UserMessage -> it.data.messageId
is ChatListItem.AiMessage -> it.data.messageId
else -> null
}
}.toSet()
// 중복 제거: 기존 목록의 messageId 집합과 비교
val existingIds: Set<Long> = items.mapNotNull {
when (it) {
is ChatListItem.UserMessage -> it.data.messageId
is ChatListItem.AiMessage -> it.data.messageId
else -> null
}
}.toSet()
val newChatItems: List<ChatListItem> = sorted
.map { it.toDomain() }
.filter { !existingIds.contains(it.messageId) }
.map { domain ->
if (domain.mine) ChatListItem.UserMessage(domain)
else ChatListItem.AiMessage(domain, characterInfo?.name)
val newChatItems: List<ChatListItem> = sorted
.map { it.toDomain() }
.filter { !existingIds.contains(it.messageId) }
.map { domain ->
if (domain.mine) ChatListItem.UserMessage(domain)
else ChatListItem.AiMessage(domain, characterInfo?.name)
}
// 상단에 추가하면서 스크롤 위치 보정
prependMessages(newChatItems)
// 페이징 상태 갱신
hasMoreMessages = response.hasMore
nextCursor = response.nextCursor ?: newChatItems.firstOrNull()?.let {
when (it) {
is ChatListItem.UserMessage -> it.data.createdAt
is ChatListItem.AiMessage -> it.data.createdAt
else -> null
}
}
// 상단에 추가하면서 스크롤 위치 보정
prependMessages(newChatItems)
isLoading = false
// 페이징 상태 갱신
hasMoreMessages = response.hasMore
nextCursor = response.nextCursor ?: newChatItems.firstOrNull()?.let {
when (it) {
is ChatListItem.UserMessage -> it.data.createdAt
is ChatListItem.AiMessage -> it.data.createdAt
else -> null
}
}
isLoading = false
// 7.3: 오래된 메시지 정리(백그라운드)
compositeDisposable.add(
chatRepository.trimOldMessages(roomId, keepLatest = 200)
.subscribe({ }, { /* 무시 */ })
)
}, { error ->
isLoading = false
showToast(error.message ?: "이전 메시지를 불러오지 못했습니다.")
})
// 7.3: 오래된 메시지 정리(백그라운드)
compositeDisposable.add(
chatRepository.trimOldMessages(roomId, keepLatest = 200)
.subscribe({ }, { /* 무시 */ })
)
}, { error ->
isLoading = false
showToast(error.message ?: "이전 메시지를 불러오지 못했습니다.")
})
compositeDisposable.add(disposable)
}

View File

@@ -0,0 +1,53 @@
/*
* 이미지 로딩 유틸리티
* - Coil을 사용하여 프로필 이미지를 로딩한다.
* - 플레이스홀더/에러 이미지는 ic_placeholder_profile을 사용한다.
* - 둥근 모서리 변환을 기본 적용한다.
*/
package kr.co.vividnext.sodalive.chat.talk.room
import android.widget.ImageView
import androidx.annotation.DrawableRes
import coil.load
import coil.transform.RoundedCornersTransformation
import kr.co.vividnext.sodalive.R
/**
* dp 값을 픽셀로 변환
*/
private fun ImageView.dpToPx(dp: Float): Float {
return dp * this.resources.displayMetrics.density
}
/**
* 프로필 이미지 로딩 공용 함수
*
* @param url 이미지 URL (null 또는 빈 값이면 플레이스홀더로 대체)
* @param cornerRadiusDp 둥근 모서리 반경(dp). 기본 12dp
* @param placeholderRes 플레이스홀더/에러 리소스
*/
fun loadProfileImage(
imageView: ImageView,
url: String?,
cornerRadiusDp: Float = 12f,
@DrawableRes placeholderRes: Int = R.drawable.ic_placeholder_profile
) {
val targetUrl = url?.takeIf { it.isNotBlank() }
val radiusPx = imageView.dpToPx(cornerRadiusDp)
if (targetUrl != null) {
imageView.load(targetUrl) {
placeholder(placeholderRes)
error(placeholderRes)
transformations(RoundedCornersTransformation(radiusPx))
crossfade(true)
}
} else {
imageView.load(placeholderRes) {
placeholder(placeholderRes)
error(placeholderRes)
transformations(RoundedCornersTransformation(radiusPx))
crossfade(true)
}
}
}