From d3a64d835982f69b4863c4ab813e598e7af97e43 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 14 Aug 2025 00:04:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-room):=20Coil=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=9C=A0=ED=8B=B8=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 채팅방의 프로필 이미지 로딩을 공용 유틸(loadProfileImage)로 통일하고 플레이스홀더/에러 처리 및 둥근 모서리 변환을 기본 적용했습니다. - ImageLoader.kt 추가: loadProfileImage(ImageView, url, cornerRadiusDp) - ChatMessageAdapter: AI 프로필 이미지 로딩에 유틸 적용 - ChatRoomActivity: 헤더 프로필 이미지 로딩에 유틸 적용 (배경 이미지는 기존 유지) --- .../chat/talk/room/ChatMessageAdapter.kt | 8 +- .../chat/talk/room/ChatRoomActivity.kt | 94 +++++++++---------- .../sodalive/chat/talk/room/ImageLoader.kt | 53 +++++++++++ 3 files changed, 101 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ImageLoader.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt index 16210dde..89b95fdb 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt @@ -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() { 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) } // 그룹 내부 간격 최소화 (상단 패딩 축소) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt index 1d53ef75..d20badd2 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt @@ -93,11 +93,8 @@ class ChatRoomActivity : BaseActivity( 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( 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( } 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 = items.mapNotNull { - when (it) { - is ChatListItem.UserMessage -> it.data.messageId - is ChatListItem.AiMessage -> it.data.messageId - else -> null - } - }.toSet() + // 중복 제거: 기존 목록의 messageId 집합과 비교 + val existingIds: Set = items.mapNotNull { + when (it) { + is ChatListItem.UserMessage -> it.data.messageId + is ChatListItem.AiMessage -> it.data.messageId + else -> null + } + }.toSet() - val newChatItems: List = 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 = 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) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ImageLoader.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ImageLoader.kt new file mode 100644 index 00000000..80d71686 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ImageLoader.kt @@ -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) + } + } +}