feat(chat-room): Coil 기반 프로필 이미지 로딩 유틸 도입 및 적용
채팅방의 프로필 이미지 로딩을 공용 유틸(loadProfileImage)로 통일하고 플레이스홀더/에러 처리 및 둥근 모서리 변환을 기본 적용했습니다. - ImageLoader.kt 추가: loadProfileImage(ImageView, url, cornerRadiusDp) - ChatMessageAdapter: AI 프로필 이미지 로딩에 유틸 적용 - ChatRoomActivity: 헤더 프로필 이미지 로딩에 유틸 적용 (배경 이미지는 기존 유지)
This commit is contained in:
		@@ -16,7 +16,6 @@ import android.widget.TextView
 | 
				
			|||||||
import androidx.annotation.LayoutRes
 | 
					import androidx.annotation.LayoutRes
 | 
				
			||||||
import androidx.core.view.isVisible
 | 
					import androidx.core.view.isVisible
 | 
				
			||||||
import androidx.recyclerview.widget.RecyclerView
 | 
					import androidx.recyclerview.widget.RecyclerView
 | 
				
			||||||
import coil.load
 | 
					 | 
				
			||||||
import kr.co.vividnext.sodalive.R
 | 
					import kr.co.vividnext.sodalive.R
 | 
				
			||||||
import kr.co.vividnext.sodalive.databinding.ItemChatAiMessageBinding
 | 
					import kr.co.vividnext.sodalive.databinding.ItemChatAiMessageBinding
 | 
				
			||||||
import kr.co.vividnext.sodalive.databinding.ItemChatTypingIndicatorBinding
 | 
					import kr.co.vividnext.sodalive.databinding.ItemChatTypingIndicatorBinding
 | 
				
			||||||
@@ -283,12 +282,9 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
                binding.tvName.text = displayName ?: ""
 | 
					                binding.tvName.text = displayName ?: ""
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // 프로필 이미지 로딩 (Coil)
 | 
					            // 프로필 이미지 로딩 (공용 유틸 + 둥근 모서리 적용)
 | 
				
			||||||
            if (binding.ivProfile.isVisible) {
 | 
					            if (binding.ivProfile.isVisible) {
 | 
				
			||||||
                binding.ivProfile.load(data.profileImageUrl) {
 | 
					                loadProfileImage(binding.ivProfile, data.profileImageUrl)
 | 
				
			||||||
                    placeholder(R.drawable.ic_placeholder_profile)
 | 
					 | 
				
			||||||
                    error(R.drawable.ic_placeholder_profile)
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // 그룹 내부 간격 최소화 (상단 패딩 축소)
 | 
					            // 그룹 내부 간격 최소화 (상단 패딩 축소)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -93,11 +93,8 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
				
			|||||||
        binding.tvName.text = info.name
 | 
					        binding.tvName.text = info.name
 | 
				
			||||||
        binding.tvName.isVisible = info.name.isNotBlank()
 | 
					        binding.tvName.isVisible = info.name.isNotBlank()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // 프로필 이미지 (Coil)
 | 
					        // 프로필 이미지 (공용 유틸 + 둥근 모서리 적용)
 | 
				
			||||||
        binding.ivProfile.load(info.profileImageUrl) {
 | 
					        loadProfileImage(binding.ivProfile, info.profileImageUrl)
 | 
				
			||||||
            placeholder(R.drawable.ic_placeholder_profile)
 | 
					 | 
				
			||||||
            error(R.drawable.ic_placeholder_profile)
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        // 배경 프로필 이미지 (5.5)
 | 
					        // 배경 프로필 이미지 (5.5)
 | 
				
			||||||
        binding.ivBackgroundProfile.load(info.profileImageUrl) {
 | 
					        binding.ivBackgroundProfile.load(info.profileImageUrl) {
 | 
				
			||||||
            placeholder(R.drawable.ic_placeholder_profile)
 | 
					            placeholder(R.drawable.ic_placeholder_profile)
 | 
				
			||||||
@@ -389,7 +386,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
				
			|||||||
        compositeDisposable.add(localDisposable)
 | 
					        compositeDisposable.add(localDisposable)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // 2) 서버 통합 API로 동기화 및 UI 갱신
 | 
					        // 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)
 | 
					        val networkDisposable = chatRepository.enterChatRoom(token = token, roomId = roomId)
 | 
				
			||||||
            .observeOn(io.reactivex.rxjava3.android.schedulers.AndroidSchedulers.mainThread())
 | 
					            .observeOn(io.reactivex.rxjava3.android.schedulers.AndroidSchedulers.mainThread())
 | 
				
			||||||
            .subscribe({ response ->
 | 
					            .subscribe({ response ->
 | 
				
			||||||
@@ -491,53 +488,54 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
        val cursor: Long? = nextCursor ?: fallbackOldestCreatedAt
 | 
					        val cursor: Long? = nextCursor ?: fallbackOldestCreatedAt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        val disposable = chatRepository.loadMoreMessages(token = token, roomId = roomId, cursor = cursor)
 | 
					        val disposable =
 | 
				
			||||||
            .observeOn(io.reactivex.rxjava3.android.schedulers.AndroidSchedulers.mainThread())
 | 
					            chatRepository.loadMoreMessages(token = token, roomId = roomId, cursor = cursor)
 | 
				
			||||||
            .subscribe({ response ->
 | 
					                .observeOn(io.reactivex.rxjava3.android.schedulers.AndroidSchedulers.mainThread())
 | 
				
			||||||
                // 서버에서 받은 메시지(이전 것들)를 오래된 -> 최신 순으로 정렬
 | 
					                .subscribe({ response ->
 | 
				
			||||||
                val sorted = response.messages.sortedBy { it.createdAt }
 | 
					                    // 서버에서 받은 메시지(이전 것들)를 오래된 -> 최신 순으로 정렬
 | 
				
			||||||
 | 
					                    val sorted = response.messages.sortedBy { it.createdAt }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                // 중복 제거: 기존 목록의 messageId 집합과 비교
 | 
					                    // 중복 제거: 기존 목록의 messageId 집합과 비교
 | 
				
			||||||
                val existingIds: Set<Long> = items.mapNotNull {
 | 
					                    val existingIds: Set<Long> = items.mapNotNull {
 | 
				
			||||||
                    when (it) {
 | 
					                        when (it) {
 | 
				
			||||||
                        is ChatListItem.UserMessage -> it.data.messageId
 | 
					                            is ChatListItem.UserMessage -> it.data.messageId
 | 
				
			||||||
                        is ChatListItem.AiMessage -> it.data.messageId
 | 
					                            is ChatListItem.AiMessage -> it.data.messageId
 | 
				
			||||||
                        else -> null
 | 
					                            else -> null
 | 
				
			||||||
                    }
 | 
					                        }
 | 
				
			||||||
                }.toSet()
 | 
					                    }.toSet()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                val newChatItems: List<ChatListItem> = sorted
 | 
					                    val newChatItems: List<ChatListItem> = sorted
 | 
				
			||||||
                    .map { it.toDomain() }
 | 
					                        .map { it.toDomain() }
 | 
				
			||||||
                    .filter { !existingIds.contains(it.messageId) }
 | 
					                        .filter { !existingIds.contains(it.messageId) }
 | 
				
			||||||
                    .map { domain ->
 | 
					                        .map { domain ->
 | 
				
			||||||
                        if (domain.mine) ChatListItem.UserMessage(domain)
 | 
					                            if (domain.mine) ChatListItem.UserMessage(domain)
 | 
				
			||||||
                        else ChatListItem.AiMessage(domain, characterInfo?.name)
 | 
					                            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
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                // 상단에 추가하면서 스크롤 위치 보정
 | 
					                    isLoading = false
 | 
				
			||||||
                prependMessages(newChatItems)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                // 페이징 상태 갱신
 | 
					                    // 7.3: 오래된 메시지 정리(백그라운드)
 | 
				
			||||||
                hasMoreMessages = response.hasMore
 | 
					                    compositeDisposable.add(
 | 
				
			||||||
                nextCursor = response.nextCursor ?: newChatItems.firstOrNull()?.let {
 | 
					                        chatRepository.trimOldMessages(roomId, keepLatest = 200)
 | 
				
			||||||
                    when (it) {
 | 
					                            .subscribe({ }, { /* 무시 */ })
 | 
				
			||||||
                        is ChatListItem.UserMessage -> it.data.createdAt
 | 
					                    )
 | 
				
			||||||
                        is ChatListItem.AiMessage -> it.data.createdAt
 | 
					                }, { error ->
 | 
				
			||||||
                        else -> null
 | 
					                    isLoading = false
 | 
				
			||||||
                    }
 | 
					                    showToast(error.message ?: "이전 메시지를 불러오지 못했습니다.")
 | 
				
			||||||
                }
 | 
					                })
 | 
				
			||||||
 | 
					 | 
				
			||||||
                isLoading = false
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                // 7.3: 오래된 메시지 정리(백그라운드)
 | 
					 | 
				
			||||||
                compositeDisposable.add(
 | 
					 | 
				
			||||||
                    chatRepository.trimOldMessages(roomId, keepLatest = 200)
 | 
					 | 
				
			||||||
                        .subscribe({ }, { /* 무시 */ })
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
            }, { error ->
 | 
					 | 
				
			||||||
                isLoading = false
 | 
					 | 
				
			||||||
                showToast(error.message ?: "이전 메시지를 불러오지 못했습니다.")
 | 
					 | 
				
			||||||
            })
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        compositeDisposable.add(disposable)
 | 
					        compositeDisposable.add(disposable)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user