fix(chat): 배경 선택 다이얼로그에서 초기 선택 복원이 되지 않는 문제 수정

- 선택 상태를 URL 비교에서 이미지 ID 우선 방식으로 변경
- URL만 저장된 기존 데이터에 대해 목록 로드 후 URL→ID 마이그레이션 추가
- SharedPreferences에 chat_bg_image_id_room_{roomId} 키 도입(호환 위해 URL 키 유지)
This commit is contained in:
2025-08-27 15:53:43 +09:00
parent 02df0b6774
commit 88e3ae7b51
6 changed files with 44 additions and 45 deletions

View File

@@ -42,7 +42,8 @@ interface TalkApi {
@GET("/api/chat/room/{roomId}/enter") @GET("/api/chat/room/{roomId}/enter")
fun enterChatRoom( fun enterChatRoom(
@Header("Authorization") authHeader: String, @Header("Authorization") authHeader: String,
@Path("roomId") roomId: Long @Path("roomId") roomId: Long,
@Query("characterImageId") characterImageId: Long?
): Single<ApiResponse<ChatRoomEnterResponse>> ): Single<ApiResponse<ChatRoomEnterResponse>>
// 메시지 전송 API // 메시지 전송 API

View File

@@ -42,12 +42,10 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
private var profileUrl: String = "" private var profileUrl: String = ""
private val prefsName = "chat_room_prefs" private val prefsName = "chat_room_prefs"
private fun bgUrlKey(roomId: Long) = "chat_bg_url_room_$roomId"
private fun bgImageIdKey(roomId: Long) = "chat_bg_image_id_room_$roomId" private fun bgImageIdKey(roomId: Long) = "chat_bg_image_id_room_$roomId"
private lateinit var adapter: BgAdapter private lateinit var adapter: BgAdapter
private val items = mutableListOf<BgItem>() private val items = mutableListOf<BgItem>()
private var selectedUrl: String? = null
private var selectedId: Long? = null private var selectedId: Long? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -88,12 +86,10 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
} }
private fun loadData() { private fun loadData() {
// 초기 선택: 저장된 값 사용 (URL + ID 병행) // 초기 선택: 저장된 이미지 ID 사용
val prefs = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE) val prefs = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE)
selectedUrl = prefs.getString(bgUrlKey(roomId), null)
val savedId = prefs.getLong(bgImageIdKey(roomId), -1L) val savedId = prefs.getLong(bgImageIdKey(roomId), -1L)
selectedId = if (savedId > 0) savedId else null selectedId = if (savedId > 0) savedId else null
items.clear() items.clear()
if (characterId > 0) { if (characterId > 0) {
@@ -105,38 +101,23 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
.subscribe({ resp -> .subscribe({ resp ->
val list = resp.data?.items.orEmpty() val list = resp.data?.items.orEmpty()
items.addAll(list.map { BgItem(id = it.id, url = it.imageUrl) }) items.addAll(list.map { BgItem(id = it.id, url = it.imageUrl) })
adapter.submit(items, selectedId, selectedUrl) adapter.submit(items, selectedId)
// 마이그레이션: 과거에 URL만 저장되어 있고 ID가 비어 있는 경우
if (selectedId == null && !selectedUrl.isNullOrBlank()) {
val found = items.firstOrNull { it.url == selectedUrl }
if (found != null) {
selectedId = found.id
// UI 갱신
adapter.updateSelected(found)
// 영구 저장(ID 동기화)
val p = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE)
p.edit {
putLong(bgImageIdKey(roomId), found.id)
}
}
}
loadingDialog.dismiss() loadingDialog.dismiss()
}, { _ -> }, { _ ->
// 실패 시에도 현재까지의 목록 표시(없으면 빈 목록) // 실패 시에도 현재까지의 목록 표시(없으면 빈 목록)
adapter.submit(items, selectedId, selectedUrl) adapter.submit(items, selectedId)
loadingDialog.dismiss() loadingDialog.dismiss()
}) })
compositeDisposable.add(d) compositeDisposable.add(d)
} else { } else {
// characterId가 없으면 서버 요청 불가: 현재 저장된 선택 상태만 반영 // characterId가 없으면 서버 요청 불가: 현재 저장된 선택 상태만 반영
adapter.submit(items, selectedId, selectedUrl) adapter.submit(items, selectedId)
} }
} }
private fun onSelect(item: BgItem) { private fun onSelect(item: BgItem) {
selectedUrl = item.url
selectedId = item.id.takeIf { it > 0 } selectedId = item.id.takeIf { it > 0 }
saveAndApply(item) saveAndApply(item)
adapter.updateSelected(item) adapter.updateSelected(item)
@@ -145,8 +126,10 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
private fun saveAndApply(item: BgItem) { private fun saveAndApply(item: BgItem) {
val prefs = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE) val prefs = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE)
prefs.edit { prefs.edit {
putString(bgUrlKey(roomId), item.url) if (item.id > 0) putLong(
if (item.id > 0) putLong(bgImageIdKey(roomId), item.id) else remove(bgImageIdKey(roomId)) bgImageIdKey(roomId),
item.id
) else remove(bgImageIdKey(roomId))
} }
(activity as? ChatRoomActivity)?.setChatBackground(item.url) (activity as? ChatRoomActivity)?.setChatBackground(item.url)
} }
@@ -155,7 +138,8 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
super.onDestroyView() super.onDestroyView()
try { try {
if (this::loadingDialog.isInitialized) loadingDialog.dismiss() if (this::loadingDialog.isInitialized) loadingDialog.dismiss()
} catch (_: Throwable) { } } catch (_: Throwable) {
}
compositeDisposable.clear() compositeDisposable.clear()
_binding = null _binding = null
} }
@@ -166,22 +150,19 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
private val onClick: (BgItem) -> Unit private val onClick: (BgItem) -> Unit
) : RecyclerView.Adapter<BgVH>() { ) : RecyclerView.Adapter<BgVH>() {
private val data = mutableListOf<BgItem>() private val data = mutableListOf<BgItem>()
private var selectedUrl: String? = null
private var selectedId: Long? = null private var selectedId: Long? = null
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun submit(items: List<BgItem>, selectedId: Long?, selectedUrl: String?) { fun submit(items: List<BgItem>, selectedId: Long?) {
data.clear() data.clear()
data.addAll(items) data.addAll(items)
this.selectedId = selectedId this.selectedId = selectedId
this.selectedUrl = selectedUrl
notifyDataSetChanged() notifyDataSetChanged()
} }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun updateSelected(item: BgItem) { fun updateSelected(item: BgItem) {
this.selectedId = item.id.takeIf { it > 0 } this.selectedId = item.id.takeIf { it > 0 }
this.selectedUrl = item.url
notifyDataSetChanged() notifyDataSetChanged()
} }
@@ -195,7 +176,7 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
override fun getItemCount(): Int = data.size override fun getItemCount(): Int = data.size
override fun onBindViewHolder(holder: BgVH, position: Int) { override fun onBindViewHolder(holder: BgVH, position: Int) {
holder.bind(data[position], selectedId, selectedUrl) holder.bind(data[position], selectedId)
} }
} }
@@ -203,13 +184,15 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
private val binding: ItemChatBackgroundImageBinding, private val binding: ItemChatBackgroundImageBinding,
private val onClick: (BgItem) -> Unit private val onClick: (BgItem) -> Unit
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: BgItem, selectedId: Long?, selectedUrl: String?) { fun bind(item: BgItem, selectedId: Long?) {
binding.ivImage.load(item.url) { binding.ivImage.load(item.url) {
placeholder(R.drawable.ic_placeholder_profile) placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile) error(R.drawable.ic_placeholder_profile)
} }
val selected = (selectedId != null && item.id == selectedId) || val selected = when {
(selectedUrl != null && selectedUrl == item.url) selectedId != null -> item.id == selectedId
else -> item.id == 0L // 저장된 ID가 없으면 프로필(id=0)을 선택으로 간주
}
binding.tvCurrent.visibility = if (selected) View.VISIBLE else View.GONE binding.tvCurrent.visibility = if (selected) View.VISIBLE else View.GONE
binding.viewBorder.visibility = if (selected) View.VISIBLE else View.GONE binding.viewBorder.visibility = if (selected) View.VISIBLE else View.GONE
binding.root.setOnClickListener { onClick(item) } binding.root.setOnClickListener { onClick(item) }

View File

@@ -86,8 +86,8 @@ class ChatRepository(
* 통합 채팅방 입장: 서버 캐릭터 정보 + 최신 메시지 수신 후 로컬 DB 업데이트 * 통합 채팅방 입장: 서버 캐릭터 정보 + 최신 메시지 수신 후 로컬 DB 업데이트
* - 로컬 데이터가 없더라도 서버 응답을 기준으로 동기화 * - 로컬 데이터가 없더라도 서버 응답을 기준으로 동기화
*/ */
fun enterChatRoom(token: String, roomId: Long): Single<ChatRoomEnterResponse> { fun enterChatRoom(token: String, roomId: Long, characterImageId: Long?): Single<ChatRoomEnterResponse> {
return talkApi.enterChatRoom(authHeader = token, roomId = roomId) return talkApi.enterChatRoom(authHeader = token, roomId = roomId, characterImageId = characterImageId)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.flatMap { response -> .flatMap { response ->
val data = ensureSuccess(response) val data = ensureSuccess(response)

View File

@@ -96,8 +96,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
binding.tvName.text = "" binding.tvName.text = ""
binding.ivProfile.setImageResource(R.drawable.ic_placeholder_profile) binding.ivProfile.setImageResource(R.drawable.ic_placeholder_profile)
binding.ivBackgroundProfile.setImageResource(R.drawable.ic_placeholder_profile) binding.ivBackgroundProfile.setImageResource(R.drawable.ic_placeholder_profile)
// 저장된 배경이 있으면 즉시 적용 (네트워크 응답 전 초기 진입에서도 반영) // 배경은 서버 응답 수신 시 적용 (기본은 플레이스홀더 유지)
applyBackgroundFromPrefsOrProfile("")
// 배지는 기본 Clone으로 둔다가 실제 값으로 갱신 (디자인 기본 배경도 clone) // 배지는 기본 Clone으로 둔다가 실제 값으로 갱신 (디자인 기본 배경도 clone)
binding.tvCharacterTypeBadge.text = "Clone" binding.tvCharacterTypeBadge.text = "Clone"
binding.tvCharacterTypeBadge.setBackgroundResource(R.drawable.bg_character_status_clone) binding.tvCharacterTypeBadge.setBackgroundResource(R.drawable.bg_character_status_clone)
@@ -121,8 +120,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
// 프로필 이미지 (공용 유틸 + 둥근 모서리 적용) // 프로필 이미지 (공용 유틸 + 둥근 모서리 적용)
loadProfileImage(binding.ivProfile, info.profileImageUrl) loadProfileImage(binding.ivProfile, info.profileImageUrl)
// 배경 이미지: 저장된 값 우선, 없으면 프로필로 저장/적용 // 배경 이미지는 서버 응답의 backgroundImageUrl을 우선 적용 (enter 완료 시 처리)
applyBackgroundFromPrefsOrProfile(info.profileImageUrl)
// 타입 배지 텍스트 및 배경 // 타입 배지 텍스트 및 배경
val (badgeText, badgeBg) = when (info.characterType) { val (badgeText, badgeBg) = when (info.characterType) {
@@ -607,12 +605,21 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
// 2) 서버 통합 API로 동기화 및 UI 갱신 // 2) 서버 통합 API로 동기화 및 UI 갱신
val token = "Bearer ${SharedPreferenceManager.token}" val token = "Bearer ${SharedPreferenceManager.token}"
val networkDisposable = chatRepository.enterChatRoom(token = token, roomId = roomId) val bgImageId = getSavedBackgroundImageId()
val networkDisposable = chatRepository.enterChatRoom(token = token, roomId = roomId, characterImageId = bgImageId)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe({ response -> .subscribe({ response ->
// 캐릭터 정보 바인딩 // 캐릭터 정보 바인딩
setCharacterInfo(response.character) setCharacterInfo(response.character)
// 배경 이미지 적용: 서버 응답의 backgroundImageUrl 우선, 없으면 프로필 사용
val bgUrl = response.backgroundImageUrl ?: response.character.profileImageUrl
if (bgUrl.isNotBlank()) {
setChatBackground(bgUrl)
} else {
binding.ivBackgroundProfile.setImageResource(R.drawable.ic_placeholder_profile)
}
// 메시지 정렬(오래된 -> 최신) + 동시간대는 messageId 오름차순으로 안정화 // 메시지 정렬(오래된 -> 최신) + 동시간대는 messageId 오름차순으로 안정화
val sorted = val sorted =
response.messages.sortedWith(compareBy<ServerChatMessage> { it.createdAt }.thenBy { it.messageId }) response.messages.sortedWith(compareBy<ServerChatMessage> { it.createdAt }.thenBy { it.messageId })
@@ -870,6 +877,13 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
fun getCharacterProfileUrl(): String = characterInfo?.profileImageUrl ?: "" fun getCharacterProfileUrl(): String = characterInfo?.profileImageUrl ?: ""
private fun bgImageIdPrefKey(): String = "chat_bg_image_id_room_$roomId"
private fun getSavedBackgroundImageId(): Long? {
val id = prefs.getLong(bgImageIdPrefKey(), -1L)
return if (id > 0) id else null
}
fun onResetChatRequested() { fun onResetChatRequested() {
val title = "대화 초기화" val title = "대화 초기화"
val desc = "지금까지의 대화가 모두 초기화 되고 새롭게 대화를 시작합니다." val desc = "지금까지의 대화가 모두 초기화 되고 새롭게 대화를 시작합니다."

View File

@@ -13,5 +13,6 @@ data class ChatRoomEnterResponse(
@SerializedName("messages") val messages: List<ServerChatMessage>, @SerializedName("messages") val messages: List<ServerChatMessage>,
@SerializedName("hasMoreMessages") val hasMoreMessages: Boolean, @SerializedName("hasMoreMessages") val hasMoreMessages: Boolean,
@SerializedName("totalRemaining") val totalRemaining: Int = 0, @SerializedName("totalRemaining") val totalRemaining: Int = 0,
@SerializedName("nextRechargeAtEpoch") val nextRechargeAtEpoch: Long? = null @SerializedName("nextRechargeAtEpoch") val nextRechargeAtEpoch: Long? = null,
@SerializedName("bgImageUrl") val backgroundImageUrl: String? = null
) )

View File

@@ -27,10 +27,10 @@ class ChatRepositoryTest {
val character = CharacterInfo(10, "name", "", CharacterType.CLONE) val character = CharacterInfo(10, "name", "", CharacterType.CLONE)
val resp = ChatRoomEnterResponse(99, character, serverMessages, hasMoreMessages = false) val resp = ChatRoomEnterResponse(99, character, serverMessages, hasMoreMessages = false)
every { api.enterChatRoom(any(), any()) } returns Single.just(ApiResponse(true, resp, null)) every { api.enterChatRoom(any(), any(), any()) } returns Single.just(ApiResponse(true, resp, null))
coEvery { dao.getNthLatestCreatedAt(any(), any()) } returns null coEvery { dao.getNthLatestCreatedAt(any(), any()) } returns null
val result = repo.enterChatRoom("token", 99).blockingGet() val result = repo.enterChatRoom("token", 99, null).blockingGet()
// 반환 검증 // 반환 검증
assertEquals(99, result.roomId) assertEquals(99, result.roomId)