댓글 목록 문자열 리소스화

댓글/답글 UI, 시간 포맷, 오류 문구 다국어 적용
This commit is contained in:
2025-12-01 17:15:59 +09:00
parent 3cf24c2ab6
commit 101c396ac2
15 changed files with 203 additions and 83 deletions

View File

@@ -17,6 +17,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.UiText
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentCharacterCommentListBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
@@ -173,8 +174,7 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
}
viewModel.toastLiveData.observe(viewLifecycleOwner) { msg ->
msg?.let { showToast(it) }
msg?.let { showToast(it.asString(requireContext())) }
}
viewModel.totalCommentCount.observe(viewLifecycleOwner) { count ->

View File

@@ -6,14 +6,16 @@ import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.UiText
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
class CharacterCommentListViewModel(
private val repository: CharacterCommentRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
private val _toastLiveData = MutableLiveData<UiText?>()
val toastLiveData: LiveData<UiText?>
get() = _toastLiveData
private val _isLoading = MutableLiveData(false)
@@ -80,14 +82,16 @@ class CharacterCommentListViewModel(
// 응답 아이템 전달 (비어있어도 전달) — UI는 addAll 처리
_commentList.postValue(items)
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
val message = resp.message?.takeIf { it.isNotBlank() }
?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.common_error_unknown)
_toastLiveData.postValue(message)
onFailure?.invoke()
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character comments load failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
_toastLiveData.postValue(UiText.StringResource(R.string.common_error_unknown))
onFailure?.invoke()
})
)
@@ -95,7 +99,7 @@ class CharacterCommentListViewModel(
fun createComment(characterId: Long, comment: String) {
if (comment.isBlank()) {
_toastLiveData.postValue("내용을 입력하세요")
_toastLiveData.postValue(UiText.StringResource(R.string.character_comment_error_empty))
return
}
if (_isLoading.value == true) return
@@ -119,13 +123,15 @@ class CharacterCommentListViewModel(
cursor = null
getCommentList(characterId)
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
val message = resp.message?.takeIf { it.isNotBlank() }
?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.common_error_unknown)
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character comment create failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
_toastLiveData.postValue(UiText.StringResource(R.string.common_error_unknown))
})
)
}
@@ -151,20 +157,24 @@ class CharacterCommentListViewModel(
cursor = null
getCommentList(characterId)
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
val message = resp.message?.takeIf { it.isNotBlank() }
?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.common_error_unknown)
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character comment delete failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
_toastLiveData.postValue(UiText.StringResource(R.string.common_error_unknown))
})
)
}
fun reportComment(characterId: Long, commentId: Long, reason: String) {
if (reason.isBlank()) {
_toastLiveData.postValue("신고 사유를 입력하세요")
_toastLiveData.postValue(
UiText.StringResource(R.string.character_comment_error_report_reason)
)
return
}
if (_isLoading.value == true) return
@@ -182,15 +192,19 @@ class CharacterCommentListViewModel(
.subscribe({ resp ->
_isLoading.value = false
if (resp.success) {
_toastLiveData.postValue("신고가 접수되었습니다.")
_toastLiveData.postValue(
UiText.StringResource(R.string.character_comment_report_submitted)
)
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
val message = resp.message?.takeIf { it.isNotBlank() }
?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.common_error_unknown)
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character comment report failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
_toastLiveData.postValue(UiText.StringResource(R.string.common_error_unknown))
})
)
}

View File

@@ -60,6 +60,7 @@ class CharacterReplyHeaderVH(
) : CharacterReplyVH(binding) {
override fun bind(item: Any) {
val data = item as CharacterCommentResponse
val context = binding.root.context
if (data.memberProfileImage.isNotBlank()) {
binding.ivCommentProfile.load(data.memberProfileImage) {
crossfade(true)
@@ -71,7 +72,7 @@ class CharacterReplyHeaderVH(
binding.ivCommentProfile.setImageResource(R.drawable.ic_placeholder_profile)
}
binding.tvCommentNickname.text = data.memberNickname
binding.tvCommentDate.text = timeAgo(data.createdAt)
binding.tvCommentDate.text = formatCommentTime(context, data.createdAt)
binding.tvComment.text = data.comment
binding.tvWriteReply.visibility = View.GONE
binding.ivMenu.visibility = View.GONE
@@ -86,6 +87,7 @@ class CharacterReplyItemVH(
override fun bind(item: Any) {
val data = item as CharacterReplyResponse
val context = binding.root.context
if (data.memberProfileImage.isNotBlank()) {
binding.ivCommentProfile.load(data.memberProfileImage) {
crossfade(true)
@@ -98,7 +100,7 @@ class CharacterReplyItemVH(
}
binding.tvCommentNickname.text = data.memberNickname
binding.tvCommentDate.text = timeAgo(data.createdAt)
binding.tvCommentDate.text = formatCommentTime(context, data.createdAt)
binding.tvComment.text = data.comment
val isOwner = data.memberId == currentUserId
@@ -109,17 +111,3 @@ class CharacterReplyItemVH(
}
}
}
private fun timeAgo(createdAtMillis: Long): String {
val now = System.currentTimeMillis()
val diff = (now - createdAtMillis).coerceAtLeast(0)
val minutes = diff / 60_000
if (minutes < 1) return "방금전"
if (minutes < 60) return "${minutes}분전"
val hours = minutes / 60
if (hours < 24) return "${hours}시간전"
val days = hours / 24
if (days < 365) return "${days}일전"
val years = days / 365
return "${years}년전"
}

View File

@@ -15,6 +15,7 @@ import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.UiText
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentCharacterCommentReplyBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
@@ -179,7 +180,7 @@ class CharacterCommentReplyFragment : BaseFragment<FragmentCharacterCommentReply
if (loading) loadingDialog.show(screenWidth) else loadingDialog.dismiss()
}
viewModel.toastLiveData.observe(viewLifecycleOwner) { msg ->
msg?.let { showToast(it) }
msg?.let { showToast(it.asString(requireContext())) }
}
viewModel.replies.observe(viewLifecycleOwner) { list ->
// 헤더(원본 댓글)는 index 0에 유지, 나머지를 교체

View File

@@ -6,14 +6,16 @@ import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.UiText
class CharacterCommentReplyViewModel(
private val repository: CharacterCommentRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?> get() = _toastLiveData
private val _toastLiveData = MutableLiveData<UiText?>()
val toastLiveData: LiveData<UiText?> get() = _toastLiveData
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean> get() = _isLoading
@@ -65,20 +67,22 @@ class CharacterCommentReplyViewModel(
cursor = resp.data.cursor
page += 1
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
val message = resp.message?.takeIf { it.isNotBlank() }
?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.common_error_unknown)
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character replies load failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
_toastLiveData.postValue(UiText.StringResource(R.string.common_error_unknown))
})
)
}
fun createReply(characterId: Long, comment: String) {
if (comment.isBlank()) {
_toastLiveData.postValue("내용을 입력하세요")
_toastLiveData.postValue(UiText.StringResource(R.string.character_comment_error_empty))
return
}
val originalId = _original.value?.commentId ?: return
@@ -110,13 +114,15 @@ class CharacterCommentReplyViewModel(
val current = _replies.value ?: emptyList()
_replies.postValue(current + listOf(me))
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
val message = resp.message?.takeIf { it.isNotBlank() }
?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.common_error_unknown)
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character reply create failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
_toastLiveData.postValue(UiText.StringResource(R.string.common_error_unknown))
})
)
}
@@ -139,20 +145,24 @@ class CharacterCommentReplyViewModel(
val current = _replies.value ?: emptyList()
_replies.postValue(current.filterNot { it.replyId == replyId })
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
val message = resp.message?.takeIf { it.isNotBlank() }
?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.common_error_unknown)
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character reply delete failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
_toastLiveData.postValue(UiText.StringResource(R.string.common_error_unknown))
})
)
}
fun reportReply(characterId: Long, replyId: Long, reason: String) {
if (reason.isBlank()) {
_toastLiveData.postValue("신고 사유를 입력하세요")
_toastLiveData.postValue(
UiText.StringResource(R.string.character_comment_error_report_reason)
)
return
}
if (_isLoading.value == true) return
@@ -170,15 +180,19 @@ class CharacterCommentReplyViewModel(
.subscribe({ resp ->
_isLoading.value = false
if (resp.success) {
_toastLiveData.postValue("신고가 접수되었습니다.")
_toastLiveData.postValue(
UiText.StringResource(R.string.character_comment_report_submitted)
)
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
val message = resp.message?.takeIf { it.isNotBlank() }
?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.common_error_unknown)
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character reply report failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
_toastLiveData.postValue(UiText.StringResource(R.string.common_error_unknown))
})
)
}

View File

@@ -27,12 +27,14 @@ class CharacterCommentReportBottomSheet : BottomSheetDialogFragment() {
var onSubmit: ((String) -> Unit)? = null
private var reasons: ArrayList<String>? = null
private var reasons: ArrayList<String> = ArrayList()
private var selectedIndex: Int = -1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
reasons = arguments?.getStringArrayList(ARG_REASONS) ?: DEFAULT_REASONS
val defaultReasons = resources.getStringArray(R.array.character_comment_report_reasons)
.toCollection(ArrayList())
reasons = arguments?.getStringArrayList(ARG_REASONS) ?: defaultReasons
}
override fun onCreateView(
@@ -49,7 +51,7 @@ class CharacterCommentReportBottomSheet : BottomSheetDialogFragment() {
tvTitle.text = getString(R.string.report_title)
setReportEnabled(btnReport, false)
val items = reasons ?: DEFAULT_REASONS
val items = reasons
val textColor = ContextCompat.getColor(requireContext(), R.color.white)
// RadioButton 동적 생성 및 단일 선택 처리
@@ -65,7 +67,8 @@ class CharacterCommentReportBottomSheet : BottomSheetDialogFragment() {
// 폰트: pretendard_regular
try {
typeface = ResourcesCompat.getFont(context, R.font.pretendard_regular)
} catch (_: Exception) { /* 폰트 미존재 대비 안전 처리 */ }
} catch (_: Exception) { /* 폰트 미존재 대비 안전 처리 */
}
// 항목 간 간격: 기존 paddingVertical 12dp의 1.3배 -> 15.6dp
val vPadPx = (14f * resources.displayMetrics.density).toInt()
setPadding(paddingLeft, vPadPx, paddingRight, vPadPx)
@@ -106,16 +109,6 @@ class CharacterCommentReportBottomSheet : BottomSheetDialogFragment() {
companion object {
private const val ARG_REASONS = "arg_reasons"
private val DEFAULT_REASONS = arrayListOf(
"원치 않는 상업성 콘텐츠 또는 스팸",
"아동 학대",
"증오시 표현 또는 노골적인 폭력",
"테러 조장",
"희롱 또는 괴롭힘",
"자살 또는 자해",
"잘못된 정보"
)
fun newInstance(reasons: ArrayList<String>? = null): CharacterCommentReportBottomSheet {
return CharacterCommentReportBottomSheet().apply {
arguments = bundleOf(ARG_REASONS to reasons)

View File

@@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.chat.character.comment
import android.content.Context
import kr.co.vividnext.sodalive.R
fun formatCommentTime(context: Context, createdAtMillis: Long): String {
val now = System.currentTimeMillis()
val diff = (now - createdAtMillis).coerceAtLeast(0)
val minutes = diff / 60_000
return when {
minutes < 1 -> context.getString(R.string.character_comment_time_just_now)
minutes < 60 -> context.getString(R.string.character_comment_time_minutes, minutes)
minutes < 1_440 -> {
val hours = minutes / 60
context.getString(R.string.character_comment_time_hours, hours)
}
minutes < 525_600 -> {
val days = minutes / 1_440
context.getString(R.string.character_comment_time_days, days)
}
else -> {
val years = minutes / 525_600
context.getString(R.string.character_comment_time_years, years)
}
}
}

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.chat.character.comment
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -20,6 +21,7 @@ class CharacterCommentsAdapter(
inner class VH(private val binding: ItemCharacterCommentBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: CharacterCommentResponse) {
val context = binding.root.context
if (item.memberProfileImage.isNotBlank()) {
binding.ivCommentProfile.load(item.memberProfileImage) {
crossfade(true)
@@ -32,12 +34,15 @@ class CharacterCommentsAdapter(
}
binding.tvCommentNickname.text = item.memberNickname
binding.tvCommentDate.text = timeAgo(item.createdAt)
binding.tvCommentDate.text = formatCommentTime(context, item.createdAt)
binding.tvComment.text = item.comment
binding.tvWriteReply.text = if (item.replyCount > 0) {
"답글 ${item.replyCount}"
context.getString(
R.string.character_comment_reply_count,
item.replyCount
)
} else {
"답글 쓰기"
context.getString(R.string.character_comment_write_reply)
}
val isOwner = item.memberId == currentUserId
@@ -61,17 +66,3 @@ class CharacterCommentsAdapter(
override fun getItemCount(): Int = items.size
}
private fun timeAgo(createdAtMillis: Long): String {
val now = System.currentTimeMillis()
val diff = (now - createdAtMillis).coerceAtLeast(0)
val minutes = diff / 60_000
if (minutes < 1) return "방금전"
if (minutes < 60) return "${minutes}분전"
val hours = minutes / 60
if (hours < 24) return "${hours}시간전"
val days = hours / 24
if (days < 365) return "${days}일전"
val years = days / 365
return "${years}년전"
}