diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f8f08c43..aa20e889 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -134,6 +134,7 @@
android:windowSoftInputMode="stateAlwaysHidden|adjustPan" />
+
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt
index bc32506f..a1b45b3a 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt
@@ -71,6 +71,7 @@ import kr.co.vividnext.sodalive.common.ApiBuilder
import kr.co.vividnext.sodalive.explorer.ExplorerApi
import kr.co.vividnext.sodalive.explorer.ExplorerRepository
import kr.co.vividnext.sodalive.explorer.ExplorerViewModel
+import kr.co.vividnext.sodalive.explorer.profile.channel_donation.UserProfileChannelDonationAllViewModel
import kr.co.vividnext.sodalive.explorer.profile.UserProfileViewModel
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityApi
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
@@ -317,6 +318,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { LiveRoomDonationMessageViewModel(get()) }
viewModel { ExplorerViewModel(get()) }
viewModel { UserProfileViewModel(get(), get(), get()) }
+ viewModel { UserProfileChannelDonationAllViewModel(get()) }
viewModel { UserFollowerListViewModel(get(), get()) }
viewModel { TextMessageViewModel(get()) }
viewModel { TextMessageWriteViewModel(get()) }
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/explorer/ExplorerApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/explorer/ExplorerApi.kt
index 4a5fed03..28fed90f 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/explorer/ExplorerApi.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/explorer/ExplorerApi.kt
@@ -8,6 +8,8 @@ import kr.co.vividnext.sodalive.explorer.profile.GetCreatorProfileResponse
import kr.co.vividnext.sodalive.explorer.profile.detail.GetCreatorDetailResponse
import kr.co.vividnext.sodalive.explorer.profile.cheers.PostWriteCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.cheers.PutModifyCheersRequest
+import kr.co.vividnext.sodalive.explorer.profile.channel_donation.GetChannelDonationListResponse
+import kr.co.vividnext.sodalive.explorer.profile.channel_donation.PostChannelDonationRequest
import kr.co.vividnext.sodalive.explorer.profile.donation.GetDonationAllResponse
import kr.co.vividnext.sodalive.explorer.profile.follow.GetFollowerListResponse
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
@@ -80,6 +82,18 @@ interface ExplorerApi {
@Header("Authorization") authHeader: String
): Single>
+ @POST("/explorer/profile/channel-donation")
+ fun postChannelDonation(
+ @Body request: PostChannelDonationRequest,
+ @Header("Authorization") authHeader: String
+ ): Single>
+
+ @GET("/explorer/profile/channel-donation")
+ fun getChannelDonationList(
+ @Query("creatorId") creatorId: Long,
+ @Header("Authorization") authHeader: String
+ ): Single>
+
@GET("/explorer/profile/{id}/follower-list")
fun getFollowerList(
@Path("id") userId: Long,
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/explorer/ExplorerRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/explorer/ExplorerRepository.kt
index a79c2861..32e78aed 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/explorer/ExplorerRepository.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/explorer/ExplorerRepository.kt
@@ -7,6 +7,8 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.explorer.profile.GetCheersResponse
import kr.co.vividnext.sodalive.explorer.profile.cheers.PostWriteCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.cheers.PutModifyCheersRequest
+import kr.co.vividnext.sodalive.explorer.profile.channel_donation.GetChannelDonationListResponse
+import kr.co.vividnext.sodalive.explorer.profile.channel_donation.PostChannelDonationRequest
import kr.co.vividnext.sodalive.explorer.profile.donation.GetDonationAllResponse
import java.util.TimeZone
@@ -69,6 +71,22 @@ class ExplorerRepository(
authHeader = token
)
+ fun postChannelDonation(
+ request: PostChannelDonationRequest,
+ token: String
+ ) = api.postChannelDonation(
+ request = request,
+ authHeader = token
+ )
+
+ fun getChannelDonationList(
+ creatorId: Long,
+ token: String
+ ): Single> = api.getChannelDonationList(
+ creatorId = creatorId,
+ authHeader = token
+ )
+
fun getFollowerList(
userId: Long,
page: Int,
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/GetCreatorProfileResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/GetCreatorProfileResponse.kt
index 28be4da8..da8e6359 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/GetCreatorProfileResponse.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/GetCreatorProfileResponse.kt
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.explorer.profile
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.audio_content.series.GetSeriesListResponse
+import kr.co.vividnext.sodalive.explorer.profile.channel_donation.GetChannelDonationListItem
import kr.co.vividnext.sodalive.explorer.profile.creator_community.GetCommunityPostListResponse
@Keep
@@ -25,10 +26,10 @@ data class GetCreatorProfileResponse(
val notice: String,
@SerializedName("communityPostList")
val communityPostList: List,
+ @SerializedName("channelDonationList")
+ val channelDonationList: List,
@SerializedName("cheers")
val cheers: GetCheersResponse,
- @SerializedName("activitySummary")
- val activitySummary: GetCreatorActivitySummary,
@SerializedName("seriesList")
val seriesList: List,
@SerializedName("isBlock")
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/UserProfileActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/UserProfileActivity.kt
index 2ea137da..d4a80840 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/UserProfileActivity.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/UserProfileActivity.kt
@@ -46,6 +46,9 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityUserProfileBinding
import kr.co.vividnext.sodalive.databinding.ItemCreatorCommunityBinding
import kr.co.vividnext.sodalive.dialog.MemberProfileDialog
+import kr.co.vividnext.sodalive.explorer.profile.channel_donation.GetChannelDonationListItem
+import kr.co.vividnext.sodalive.explorer.profile.channel_donation.UserProfileChannelDonationAdapter
+import kr.co.vividnext.sodalive.explorer.profile.channel_donation.UserProfileChannelDonationAllViewActivity
import kr.co.vividnext.sodalive.explorer.profile.cheers.UserProfileCheersAdapter
import kr.co.vividnext.sodalive.explorer.profile.creator_community.GetCommunityPostListResponse
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
@@ -65,6 +68,7 @@ import kr.co.vividnext.sodalive.live.reservation.complete.LiveReservationComplet
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
import kr.co.vividnext.sodalive.live.room.dialog.LivePaymentDialog
import kr.co.vividnext.sodalive.live.room.dialog.LiveRoomPasswordDialog
+import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationDialog
import kr.co.vividnext.sodalive.live.room.menu.MenuConfigActivity
import kr.co.vividnext.sodalive.live.roulette.config.RouletteConfigActivity
import kr.co.vividnext.sodalive.report.CheersReportDialog
@@ -92,6 +96,7 @@ class UserProfileActivity : BaseActivity(
private lateinit var audioContentAdapter: AudioContentAdapter
private lateinit var seriesAdapter: UserProfileSeriesListAdapter
private lateinit var donationAdapter: UserProfileDonationAdapter
+ private lateinit var channelDonationAdapter: UserProfileChannelDonationAdapter
private lateinit var cheersAdapter: UserProfileCheersAdapter
private val handler = Handler(Looper.getMainLooper())
@@ -137,6 +142,7 @@ class UserProfileActivity : BaseActivity(
setupLiveView()
setupDonationView()
+ setupChannelDonationView()
setupFanTalkView()
setupSeriesListView()
setupAudioContentListView()
@@ -338,6 +344,68 @@ class UserProfileActivity : BaseActivity(
setupCheersView()
}
+ private fun setupChannelDonationView() {
+ binding.layoutUserProfileChannelDonation.tvAll.setOnClickListener {
+ val intent = Intent(applicationContext, UserProfileChannelDonationAllViewActivity::class.java)
+ intent.putExtra(Constants.EXTRA_USER_ID, userId)
+ startActivity(intent)
+ }
+
+ binding.layoutUserProfileChannelDonation.llChannelDonation.setOnClickListener {
+ val dialog = LiveRoomDonationDialog(
+ this,
+ LayoutInflater.from(this),
+ isLiveDonation = true,
+ messageMaxLength = 100
+ ) { can, message, isSecret ->
+ viewModel.postChannelDonation(
+ creatorId = userId,
+ can = can,
+ isSecret = isSecret,
+ message = message
+ ) {
+ viewModel.getCreatorProfile(userId)
+ }
+ }
+ dialog.show(screenWidth - 26.7f.dpToPx().toInt())
+ }
+
+ val recyclerView = binding.layoutUserProfileChannelDonation.rvChannelDonation
+ channelDonationAdapter = UserProfileChannelDonationAdapter()
+ recyclerView.layoutManager = LinearLayoutManager(
+ applicationContext,
+ LinearLayoutManager.HORIZONTAL,
+ false
+ )
+ recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State
+ ) {
+ super.getItemOffsets(outRect, view, parent, state)
+ when (parent.getChildAdapterPosition(view)) {
+ 0 -> {
+ outRect.left = 13.3f.dpToPx().toInt()
+ outRect.right = 6.7f.dpToPx().toInt()
+ }
+
+ channelDonationAdapter.itemCount - 1 -> {
+ outRect.left = 6.7f.dpToPx().toInt()
+ outRect.right = 13.3f.dpToPx().toInt()
+ }
+
+ else -> {
+ outRect.left = 6.7f.dpToPx().toInt()
+ outRect.right = 6.7f.dpToPx().toInt()
+ }
+ }
+ }
+ })
+ recyclerView.adapter = channelDonationAdapter
+ }
+
private fun setupCheersView() {
binding.layoutUserProfileFanTalk.ivSend.setOnClickListener {
hideKeyboard {
@@ -616,9 +684,12 @@ class UserProfileActivity : BaseActivity(
it.totalContentCount,
it.ownedContentCount
)
+ setChannelDonationList(it.channelDonationList)
setLiveRoomList(it.liveRoomList)
setUserDonationRanking(it.userDonationRanking)
setCommunityPostList(it.communityPostList)
+ } else {
+ binding.layoutUserProfileChannelDonation.root.visibility = View.GONE
}
}
}
@@ -968,6 +1039,21 @@ class UserProfileActivity : BaseActivity(
}
}
+ @SuppressLint("NotifyDataSetChanged")
+ private fun setChannelDonationList(channelDonationItems: List) {
+ binding.layoutUserProfileChannelDonation.root.visibility = View.VISIBLE
+ binding.layoutUserProfileChannelDonation.tvNoChannelDonation.visibility =
+ if (channelDonationItems.isEmpty()) View.VISIBLE else View.GONE
+ binding.layoutUserProfileChannelDonation.rvChannelDonation.visibility =
+ if (channelDonationItems.isEmpty()) View.GONE else View.VISIBLE
+ binding.layoutUserProfileChannelDonation.tvAll.visibility =
+ if (channelDonationItems.isEmpty()) View.GONE else View.VISIBLE
+
+ channelDonationAdapter.items.clear()
+ channelDonationAdapter.items.addAll(channelDonationItems)
+ channelDonationAdapter.notifyDataSetChanged()
+ }
+
private fun setCommunityPost(
layout: ItemCreatorCommunityBinding,
item: GetCommunityPostListResponse,
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/UserProfileViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/UserProfileViewModel.kt
index 225982cf..f87f5885 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/UserProfileViewModel.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/UserProfileViewModel.kt
@@ -12,6 +12,8 @@ import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.common.Utils
import kr.co.vividnext.sodalive.explorer.ExplorerRepository
import kr.co.vividnext.sodalive.explorer.profile.cheers.PutModifyCheersRequest
+import kr.co.vividnext.sodalive.explorer.profile.channel_donation.GetChannelDonationListResponse
+import kr.co.vividnext.sodalive.explorer.profile.channel_donation.PostChannelDonationRequest
import kr.co.vividnext.sodalive.explorer.profile.detail.GetCreatorDetailResponse
import kr.co.vividnext.sodalive.report.ReportRepository
import kr.co.vividnext.sodalive.report.ReportRequest
@@ -35,6 +37,10 @@ class UserProfileViewModel(
val creatorProfileLiveData: LiveData
get() = _creatorProfileLiveData
+ private val _channelDonationLiveData = MutableLiveData()
+ val channelDonationLiveData: LiveData
+ get() = _channelDonationLiveData
+
fun cheersReport(cheersId: Long, reason: String) {
_isLoading.value = true
@@ -356,6 +362,89 @@ class UserProfileViewModel(
onSuccess("보이스온 ${nickname}님의 채널입니다.\n$shareUrl")
}
+ fun getChannelDonationList(creatorId: Long) {
+ compositeDisposable.add(
+ repository.getChannelDonationList(
+ creatorId = creatorId,
+ token = "Bearer ${SharedPreferenceManager.token}"
+ )
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ {
+ if (it.success && it.data != null) {
+ _channelDonationLiveData.postValue(it.data)
+ } else {
+ if (it.message != null) {
+ _toastLiveData.postValue(it.message)
+ } else {
+ _toastLiveData.postValue(
+ SodaLiveApplicationHolder.get()
+ .getString(R.string.common_error_unknown)
+ )
+ }
+ }
+ },
+ {
+ it.message?.let { message -> Logger.e(message) }
+ _toastLiveData.postValue(
+ SodaLiveApplicationHolder.get()
+ .getString(R.string.common_error_unknown)
+ )
+ }
+ )
+ )
+ }
+
+ fun postChannelDonation(
+ creatorId: Long,
+ can: Int,
+ isSecret: Boolean,
+ message: String,
+ onSuccess: () -> Unit
+ ) {
+ _isLoading.value = true
+ compositeDisposable.add(
+ repository.postChannelDonation(
+ request = PostChannelDonationRequest(
+ creatorId = creatorId,
+ can = can,
+ isSecret = isSecret,
+ message = message
+ ),
+ token = "Bearer ${SharedPreferenceManager.token}"
+ )
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ {
+ _isLoading.value = false
+ if (it.success) {
+ SharedPreferenceManager.can -= can
+ onSuccess()
+ } else {
+ if (it.message != null) {
+ _toastLiveData.postValue(it.message)
+ } else {
+ _toastLiveData.postValue(
+ SodaLiveApplicationHolder.get()
+ .getString(R.string.common_error_unknown)
+ )
+ }
+ }
+ },
+ {
+ _isLoading.value = false
+ it.message?.let { message -> Logger.e(message) }
+ _toastLiveData.postValue(
+ SodaLiveApplicationHolder.get()
+ .getString(R.string.common_error_unknown)
+ )
+ }
+ )
+ )
+ }
+
fun userBlock(userId: Long) {
_isLoading.value = true
compositeDisposable.add(
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/ChannelDonationTimeFormatter.kt b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/ChannelDonationTimeFormatter.kt
new file mode 100644
index 00000000..c9915c9b
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/ChannelDonationTimeFormatter.kt
@@ -0,0 +1,93 @@
+package kr.co.vividnext.sodalive.explorer.profile.channel_donation
+
+import android.content.Context
+import kr.co.vividnext.sodalive.R
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+fun GetChannelDonationListItem.relativeTimeText(context: Context): String {
+ val pastMillis = parseServerUtcToMillis(createdAt)
+ ?: return context.getString(R.string.character_comment_time_just_now)
+
+ val nowMillis = System.currentTimeMillis()
+ var diff = nowMillis - pastMillis
+ if (diff < 0) diff = 0
+
+ val minute = 60_000L
+ val hour = 60 * minute
+ val day = 24 * hour
+
+ if (diff < minute) {
+ return context.getString(R.string.character_comment_time_just_now)
+ }
+
+ if (diff < hour) {
+ val minutes = (diff / minute).toInt()
+ return context.getString(R.string.character_comment_time_minutes, minutes)
+ }
+
+ if (diff < day) {
+ val hours = (diff / hour).toInt()
+ return context.getString(R.string.character_comment_time_hours, hours)
+ }
+
+ if (diff < 30 * day) {
+ val days = (diff / day).toInt()
+ return context.getString(R.string.character_comment_time_days, days)
+ }
+
+ val tz = TimeZone.getDefault()
+ val calNow = Calendar.getInstance(tz, Locale.getDefault())
+ val calPast = Calendar.getInstance(tz, Locale.getDefault())
+ calPast.timeInMillis = pastMillis
+
+ var years = calNow.get(Calendar.YEAR) - calPast.get(Calendar.YEAR)
+ val nowMonth = calNow.get(Calendar.MONTH)
+ val pastMonth = calPast.get(Calendar.MONTH)
+ val nowDay = calNow.get(Calendar.DAY_OF_MONTH)
+ val pastDay = calPast.get(Calendar.DAY_OF_MONTH)
+ if (nowMonth < pastMonth || (nowMonth == pastMonth && nowDay < pastDay)) {
+ years -= 1
+ }
+
+ if (years < 1) {
+ var months = (calNow.get(Calendar.YEAR) - calPast.get(Calendar.YEAR)) * 12 + (nowMonth - pastMonth)
+ if (nowDay < pastDay) months -= 1
+ if (months < 1) months = 1
+ return context.getString(R.string.character_comment_time_months, months)
+ }
+
+ return context.getString(R.string.character_comment_time_years, years)
+}
+
+private fun parseServerUtcToMillis(dateUtc: String?): Long? {
+ if (dateUtc.isNullOrBlank()) return null
+ val value = dateUtc.trim()
+
+ if (value.all { it.isDigit() }) {
+ return try { value.toLong() } catch (_: NumberFormatException) { null }
+ }
+
+ val patterns = listOf(
+ "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
+ "yyyy-MM-dd'T'HH:mm:ss'Z'",
+ "yyyy-MM-dd'T'HH:mm:ss",
+ "yyyy-MM-dd HH:mm:ss",
+ "yyyy/MM/dd HH:mm:ss"
+ )
+
+ for (pattern in patterns) {
+ try {
+ val sdf = SimpleDateFormat(pattern, Locale.US)
+ sdf.timeZone = TimeZone.getTimeZone("UTC")
+ val parsed: Date? = sdf.parse(value)
+ if (parsed != null) return parsed.time
+ } catch (_: ParseException) { }
+ }
+
+ return null
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/ChannelDonationUiFormatter.kt b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/ChannelDonationUiFormatter.kt
new file mode 100644
index 00000000..7e6fa4d4
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/ChannelDonationUiFormatter.kt
@@ -0,0 +1,56 @@
+package kr.co.vividnext.sodalive.explorer.profile.channel_donation
+
+import android.content.Context
+import android.text.SpannableString
+import android.text.Spanned
+import android.text.style.ForegroundColorSpan
+import androidx.annotation.DrawableRes
+import androidx.core.content.ContextCompat
+import kr.co.vividnext.sodalive.R
+
+@DrawableRes
+fun GetChannelDonationListItem.chatDonationBackgroundRes(): Int {
+ if (isSecret) return R.drawable.bg_round_corner_6_7_cc59548f
+ return when {
+ can >= 10000 -> R.drawable.bg_round_corner_6_7_ccc25264
+ can >= 5000 -> R.drawable.bg_round_corner_6_7_ccd85e37
+ can >= 1000 -> R.drawable.bg_round_corner_6_7_ccd38c38
+ can >= 500 -> R.drawable.bg_round_corner_6_7_cc59548f
+ can >= 100 -> R.drawable.bg_round_corner_6_7_cc4d6aa4
+ can >= 50 -> R.drawable.bg_round_corner_6_7_cc2d7390
+ else -> R.drawable.bg_round_corner_6_7_cc548f7d
+ }
+}
+
+fun GetChannelDonationListItem.channelDonationContentText(
+ context: Context,
+ maxLength: Int? = null
+) =
+ run {
+ val contentText = if (maxLength != null && message.length > maxLength) {
+ "${message.take(maxLength)}..."
+ } else {
+ message
+ }
+ val canRange = Regex("[0-9,]+\\s*캔").find(contentText)?.range
+ SpannableString(contentText).apply {
+ setSpan(
+ ForegroundColorSpan(
+ ContextCompat.getColor(context, R.color.color_cfd8dc)
+ ),
+ 0,
+ contentText.length,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ )
+ if (canRange != null) {
+ setSpan(
+ ForegroundColorSpan(
+ ContextCompat.getColor(context, R.color.color_fdca2f)
+ ),
+ canRange.first,
+ canRange.last + 1,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ )
+ }
+ }
+ }
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/GetChannelDonationListResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/GetChannelDonationListResponse.kt
new file mode 100644
index 00000000..ffd3596c
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/GetChannelDonationListResponse.kt
@@ -0,0 +1,22 @@
+package kr.co.vividnext.sodalive.explorer.profile.channel_donation
+
+import androidx.annotation.Keep
+import com.google.gson.annotations.SerializedName
+
+@Keep
+data class GetChannelDonationListResponse(
+ @SerializedName("totalCount") val totalCount: Int,
+ @SerializedName("items") val items: List
+)
+
+@Keep
+data class GetChannelDonationListItem(
+ @SerializedName("id") val id: Long,
+ @SerializedName("memberId") val memberId: Long,
+ @SerializedName("nickname") val nickname: String,
+ @SerializedName("profileUrl") val profileUrl: String,
+ @SerializedName("can") val can: Int,
+ @SerializedName("isSecret") val isSecret: Boolean,
+ @SerializedName("message") val message: String,
+ @SerializedName("createdAt") val createdAt: String
+)
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/PostChannelDonationRequest.kt b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/PostChannelDonationRequest.kt
new file mode 100644
index 00000000..d3e07a73
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/PostChannelDonationRequest.kt
@@ -0,0 +1,13 @@
+package kr.co.vividnext.sodalive.explorer.profile.channel_donation
+
+import androidx.annotation.Keep
+import com.google.gson.annotations.SerializedName
+
+@Keep
+data class PostChannelDonationRequest(
+ @SerializedName("creatorId") val creatorId: Long,
+ @SerializedName("can") val can: Int,
+ @SerializedName("isSecret") val isSecret: Boolean = false,
+ @SerializedName("message") val message: String = "",
+ @SerializedName("container") val container: String = "aos"
+)
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAdapter.kt
new file mode 100644
index 00000000..6cf348fb
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAdapter.kt
@@ -0,0 +1,51 @@
+package kr.co.vividnext.sodalive.explorer.profile.channel_donation
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import coil.load
+import coil.transform.CircleCropTransformation
+import kr.co.vividnext.sodalive.R
+import kr.co.vividnext.sodalive.databinding.ItemUserProfileChannelDonationBinding
+
+class UserProfileChannelDonationAdapter : RecyclerView.Adapter() {
+
+ val items = mutableListOf()
+
+ class ViewHolder(
+ private val binding: ItemUserProfileChannelDonationBinding
+ ) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(item: GetChannelDonationListItem) {
+ binding.ivProfile.load(item.profileUrl) {
+ crossfade(true)
+ placeholder(R.drawable.ic_place_holder)
+ transformations(CircleCropTransformation())
+ }
+
+ binding.tvNickname.text = item.nickname
+ binding.tvDate.text = item.relativeTimeText(binding.root.context)
+ binding.tvContent.text = item.channelDonationContentText(
+ context = binding.root.context,
+ maxLength = 30
+ )
+ binding.root.setBackgroundResource(item.chatDonationBackgroundRes())
+ binding.root.setOnClickListener(null)
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ return ViewHolder(
+ ItemUserProfileChannelDonationBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ )
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ holder.bind(items[position])
+ }
+
+ override fun getItemCount() = items.size
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAllAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAllAdapter.kt
new file mode 100644
index 00000000..3cad7744
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAllAdapter.kt
@@ -0,0 +1,61 @@
+package kr.co.vividnext.sodalive.explorer.profile.channel_donation
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import coil.load
+import coil.transform.CircleCropTransformation
+import kr.co.vividnext.sodalive.R
+import kr.co.vividnext.sodalive.databinding.ItemUserProfileChannelDonationAllBinding
+
+class UserProfileChannelDonationAllAdapter : RecyclerView.Adapter() {
+
+ val items = mutableListOf()
+ private val expandedItemIds = mutableSetOf()
+
+ inner class ViewHolder(
+ private val binding: ItemUserProfileChannelDonationAllBinding
+ ) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(item: GetChannelDonationListItem) {
+ binding.ivProfile.load(item.profileUrl) {
+ crossfade(true)
+ placeholder(R.drawable.ic_place_holder)
+ transformations(CircleCropTransformation())
+ }
+
+ binding.tvNickname.text = item.nickname
+ binding.tvDate.text = item.relativeTimeText(binding.root.context)
+ val isExpanded = expandedItemIds.contains(item.id)
+ binding.tvContent.text = item.channelDonationContentText(
+ context = binding.root.context,
+ maxLength = if (isExpanded) null else 30
+ )
+ binding.root.setBackgroundResource(item.chatDonationBackgroundRes())
+ binding.tvContent.setOnClickListener {
+ if (!isExpanded && item.message.length > 30) {
+ expandedItemIds.add(item.id)
+ val position = bindingAdapterPosition
+ if (position != RecyclerView.NO_POSITION) {
+ notifyItemChanged(position)
+ }
+ }
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ return ViewHolder(
+ ItemUserProfileChannelDonationAllBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ )
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ holder.bind(items[position])
+ }
+
+ override fun getItemCount() = items.size
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAllViewActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAllViewActivity.kt
new file mode 100644
index 00000000..32b27200
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAllViewActivity.kt
@@ -0,0 +1,95 @@
+package kr.co.vividnext.sodalive.explorer.profile.channel_donation
+
+import android.annotation.SuppressLint
+import android.graphics.Rect
+import android.os.Bundle
+import android.view.View
+import android.widget.Toast
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import kr.co.vividnext.sodalive.R
+import kr.co.vividnext.sodalive.base.BaseActivity
+import kr.co.vividnext.sodalive.common.Constants
+import kr.co.vividnext.sodalive.common.LoadingDialog
+import kr.co.vividnext.sodalive.databinding.ActivityUserProfileChannelDonationAllBinding
+import kr.co.vividnext.sodalive.extensions.dpToPx
+import org.koin.android.ext.android.inject
+
+class UserProfileChannelDonationAllViewActivity : BaseActivity(
+ ActivityUserProfileChannelDonationAllBinding::inflate
+) {
+
+ private val viewModel: UserProfileChannelDonationAllViewModel by inject()
+
+ private lateinit var loadingDialog: LoadingDialog
+ private lateinit var adapter: UserProfileChannelDonationAllAdapter
+
+ private var userId: Long = 0
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ userId = intent.getLongExtra(Constants.EXTRA_USER_ID, 0)
+ super.onCreate(savedInstanceState)
+
+ if (userId > 0) {
+ bindData()
+ viewModel.getChannelDonationList(userId)
+ } else {
+ Toast.makeText(
+ applicationContext,
+ getString(R.string.error_invalid_request),
+ Toast.LENGTH_LONG
+ ).show()
+ finish()
+ }
+ }
+
+ override fun setupView() {
+ loadingDialog = LoadingDialog(this, layoutInflater)
+ binding.toolbar.tvBack.setText(R.string.screen_user_profile_channel_donation_all_title)
+ binding.toolbar.tvBack.setOnClickListener { finish() }
+
+ val recyclerView = binding.rvChannelDonation
+ adapter = UserProfileChannelDonationAllAdapter()
+ recyclerView.layoutManager = LinearLayoutManager(
+ applicationContext,
+ LinearLayoutManager.VERTICAL,
+ false
+ )
+ recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State
+ ) {
+ super.getItemOffsets(outRect, view, parent, state)
+ outRect.bottom = 10.dpToPx().toInt()
+ }
+ })
+ recyclerView.adapter = adapter
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ private fun bindData() {
+ viewModel.toastLiveData.observe(this) {
+ it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
+ }
+
+ viewModel.isLoading.observe(this) {
+ if (it) {
+ loadingDialog.show(screenWidth, "")
+ } else {
+ loadingDialog.dismiss()
+ }
+ }
+
+ viewModel.channelDonationLiveData.observe(this) {
+ binding.tvTotalCount.text = it.totalCount.toString()
+ adapter.items.clear()
+ adapter.items.addAll(it.items)
+ adapter.notifyDataSetChanged()
+ binding.tvNoChannelDonation.visibility = if (it.items.isEmpty()) View.VISIBLE else View.GONE
+ binding.rvChannelDonation.visibility = if (it.items.isEmpty()) View.GONE else View.VISIBLE
+ }
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAllViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAllViewModel.kt
new file mode 100644
index 00000000..c8a9b374
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAllViewModel.kt
@@ -0,0 +1,66 @@
+package kr.co.vividnext.sodalive.explorer.profile.channel_donation
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.orhanobut.logger.Logger
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.schedulers.Schedulers
+import kr.co.vividnext.sodalive.R
+import kr.co.vividnext.sodalive.base.BaseViewModel
+import kr.co.vividnext.sodalive.common.SharedPreferenceManager
+import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
+import kr.co.vividnext.sodalive.explorer.ExplorerRepository
+
+class UserProfileChannelDonationAllViewModel(
+ private val repository: ExplorerRepository
+) : BaseViewModel() {
+
+ private val _toastLiveData = MutableLiveData()
+ val toastLiveData: LiveData
+ get() = _toastLiveData
+
+ private val _isLoading = MutableLiveData(false)
+ val isLoading: LiveData
+ get() = _isLoading
+
+ private val _channelDonationLiveData = MutableLiveData()
+ val channelDonationLiveData: LiveData
+ get() = _channelDonationLiveData
+
+ fun getChannelDonationList(creatorId: Long) {
+ _isLoading.value = true
+ compositeDisposable.add(
+ repository.getChannelDonationList(
+ creatorId = creatorId,
+ token = "Bearer ${SharedPreferenceManager.token}"
+ )
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ {
+ _isLoading.value = false
+ if (it.success && it.data != null) {
+ _channelDonationLiveData.postValue(it.data)
+ } else {
+ if (it.message != null) {
+ _toastLiveData.postValue(it.message)
+ } else {
+ _toastLiveData.postValue(
+ SodaLiveApplicationHolder.get()
+ .getString(R.string.common_error_unknown)
+ )
+ }
+ }
+ },
+ {
+ _isLoading.value = false
+ it.message?.let { message -> Logger.e(message) }
+ _toastLiveData.postValue(
+ SodaLiveApplicationHolder.get()
+ .getString(R.string.common_error_unknown)
+ )
+ }
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationDialog.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationDialog.kt
index fefc3c1a..663442d5 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationDialog.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationDialog.kt
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
+import android.text.InputFilter
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
@@ -26,6 +27,7 @@ class LiveRoomDonationDialog(
private val activity: AppCompatActivity,
layoutInflater: LayoutInflater,
isLiveDonation: Boolean = false,
+ messageMaxLength: Int = 1000,
onClickDonation: (Int, String, Boolean) -> Unit
) {
@@ -47,11 +49,17 @@ class LiveRoomDonationDialog(
bottomSheetBehavior.skipCollapsed = true
}
+ dialogView.etDonationMessage.filters = arrayOf(InputFilter.LengthFilter(messageMaxLength))
+ dialogView.etDonationMessage.hint = activity.getString(
+ R.string.screen_live_room_donation_message_hint_format,
+ messageMaxLength
+ )
+
dialogView.tvCancel.setOnClickListener { bottomSheetDialog.dismiss() }
dialogView.tvDonation.setOnClickListener {
try {
val can = dialogView.etDonationCan.text.toString().toInt()
- val message = dialogView.etDonationMessage.text.toString().prefix(1000)
+ val message = dialogView.etDonationMessage.text.toString().prefix(messageMaxLength)
if (isLiveDonation) {
val isSecret = dialogView.tvSecret.isSelected
@@ -99,9 +107,15 @@ class LiveRoomDonationDialog(
val isSelected = dialogView.tvSecret.isSelected
dialogView.tvSecret.isSelected = !isSelected
dialogView.etDonationMessage.hint = if (!isSelected) {
- activity.getString(R.string.screen_live_room_secret_mission_hint)
+ activity.getString(
+ R.string.screen_live_room_secret_mission_hint_format,
+ messageMaxLength
+ )
} else {
- activity.getString(R.string.screen_live_room_donation_message_hint)
+ activity.getString(
+ R.string.screen_live_room_donation_message_hint_format,
+ messageMaxLength
+ )
}
dialogView.etDonationCan.hint = if (!isSelected) {
activity.getString(R.string.screen_live_room_secret_mission_input_min)
diff --git a/app/src/main/res/drawable/bg_round_corner_16_525252.xml b/app/src/main/res/drawable/bg_round_corner_16_525252.xml
new file mode 100644
index 00000000..1565a179
--- /dev/null
+++ b/app/src/main/res/drawable/bg_round_corner_16_525252.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_user_profile.xml b/app/src/main/res/layout/activity_user_profile.xml
index 3755d57e..4f2963fe 100644
--- a/app/src/main/res/layout/activity_user_profile.xml
+++ b/app/src/main/res/layout/activity_user_profile.xml
@@ -277,6 +277,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_user_profile_channel_donation.xml b/app/src/main/res/layout/item_user_profile_channel_donation.xml
new file mode 100644
index 00000000..cbf60244
--- /dev/null
+++ b/app/src/main/res/layout/item_user_profile_channel_donation.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_user_profile_channel_donation_all.xml b/app/src/main/res/layout/item_user_profile_channel_donation_all.xml
new file mode 100644
index 00000000..f531b87d
--- /dev/null
+++ b/app/src/main/res/layout/item_user_profile_channel_donation_all.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/layout_user_profile_channel_donation.xml b/app/src/main/res/layout/layout_user_profile_channel_donation.xml
new file mode 100644
index 00000000..f53fab5b
--- /dev/null
+++ b/app/src/main/res/layout/layout_user_profile_channel_donation.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 10c38b87..4a334830 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -477,6 +477,8 @@
Listener
Enter a secret mission (up to 1000 chars)
Enter a message to send (up to 1000 chars)
+ Enter a secret mission (up to %1$d chars)
+ Enter a message to send (up to %1$d chars)
Enter 10 or more cans
Enter at least 1 can
Free
@@ -794,6 +796,10 @@
%1$s / %2$s items
Fan Talk
View all Fan Talk
+ Channel Donation
+ Donate to Channel
+ View all Channel Donations
+ No channel donations yet.
Cheer
Leave a cheer comment!
Leave a reply to this cheer!
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 6dff89b1..5a9dfc3f 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -476,6 +476,8 @@
リスナー
シークレットミッションを入力(最大1000文字)
一緒に送るメッセージを入力(最大1000文字)
+ シークレットミッションを入力(最大%1$d文字)
+ 一緒に送るメッセージを入力(最大%1$d文字)
10CAN以上入力してください
1CAN以上入力してください
無料
@@ -794,6 +796,10 @@
%1$s / %2$s個
ファンTalk
ファンTalkをすべて見る
+ チャンネルギフト
+ チャンネルにギフトする
+ チャンネルギフトをすべて見る
+ チャンネルギフトがありません。
応援
応援コメントを残してみましょう!
応援コメントに返信してみましょう!
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index fc062051..cdbae48e 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -137,4 +137,5 @@
#B0BEC5
#7C7C80
#37474F
+ #CFD8DC
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 25d9a786..2ef5b505 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -476,6 +476,8 @@
리스너
비밀 미션을 입력하세요(최대 1000자)
함께 보낼 메시지 입력(최대 1000자)
+ 비밀 미션을 입력하세요(최대 %1$d자)
+ 함께 보낼 메시지 입력(최대 %1$d자)
10캔 이상 입력하세요
1캔 이상 입력하세요
무료
@@ -793,6 +795,10 @@
%1$s / %2$s개
팬 Talk
팬 Talk 전체보기
+ 채널 후원
+ 채널 후원하기
+ 채널 후원 전체보기
+ 채널 후원이 없습니다.
응원
응원댓글을 남겨보세요!
응원댓글에 답글을 남겨보세요!
diff --git a/docs/20260225_채널후원영역및전체보기구현.md b/docs/20260225_채널후원영역및전체보기구현.md
new file mode 100644
index 00000000..67f92037
--- /dev/null
+++ b/docs/20260225_채널후원영역및전체보기구현.md
@@ -0,0 +1,70 @@
+# 20260225 채널 후원 영역 및 전체보기 구현
+
+## 작업 목표
+- 크리에이터 채널에 채널 후원 섹션을 추가한다.
+- 채널 후원하기 UI는 라이브 후원하기 UI와 동일한 흐름/스타일을 따른다.
+- 채널 후원 전체보기 페이지를 별도로 추가한다.
+- API 연동은 `/explorer/profile/channel-donation`의 POST/GET 요구사항을 반영한다.
+
+## 구현 체크리스트
+- [x] 기존 라이브 후원 다이얼로그/아이콘/문구 스타일 재사용 지점 확인
+- [x] `ExplorerApi`/`ExplorerRepository`에 채널 후원 POST/GET API 추가
+- [x] `UserProfileViewModel`에 채널 후원 조회/후원하기 액션 추가
+- [x] `kr.co.vividnext.sodalive.explorer.profile.channel_donation` 패키지에 전체보기 Activity/ViewModel/Adapter 생성
+- [x] 크리에이터 채널(`UserProfileActivity`)에 채널 후원 섹션 UI 추가
+- [x] 섹션 상단 `제목 - 전체보기` 및 총 개수 노출
+- [x] 가로 아이템 리스트 폭을 줄여 좌/우 아이템 일부가 보이도록 구성
+- [x] `채널 후원하기` 텍스트 버튼 스타일 적용(배경 `#525252`, radius `16dp`, 흰색 텍스트, 선물 아이콘)
+- [x] 아이템 UI 적용(프로필 이미지, 닉네임, 시간, 내용)
+- [x] `createdAt(UTC)`을 기기 타임존으로 변환해 `OO분전/OO시간전/OO일전` 표시
+- [x] 내용 텍스트에서 `OO캔` 색상 `#FDCA2F`, 나머지 `#CFD8DC`, 글자 크기 `16sp` 적용
+- [x] 문자열/리소스 추가 및 기존 다국어 리소스 반영
+- [x] LSP 진단, 테스트/빌드 실행 및 결과 확인
+
+## 검증 기록
+- 2026-02-25
+ - 무엇/왜/어떻게: 채널 후원 API(POST/GET) 연동, 프로필 채널 후원 섹션 및 후원 버튼(라이브 후원 다이얼로그 재사용), 채널 후원 전체보기 페이지를 추가하고 아이템 시간/문구 스타일을 요구사항대로 반영했다.
+ - 실행 명령: `./gradlew :app:testDebugUnitTest`
+ - 결과: 성공(BUILD SUCCESSFUL), 신규 변경으로 인한 테스트 실패 없음.
+ - 실행 명령: `./gradlew :app:assembleDebug`
+ - 결과: 성공(BUILD SUCCESSFUL), 디버그 빌드 정상 완료.
+ - 참고: 현재 실행 환경의 LSP 도구는 `.kt` 확장 LSP 서버가 구성되어 있지 않아 LSP 진단 대신 Gradle 컴파일/테스트로 정합성을 검증했다.
+
+- 2026-02-25 (후속 요구사항 반영)
+ - 무엇/왜/어떻게: 채널 후원 섹션 위치를 최신 콘텐츠 아래로 이동하고, 프로필 페이지는 `GetCreatorProfileResponse.channelDonationList`만 사용하도록 변경했다. 후원 0건 시 빈 문구 노출/전체보기 숨김, 프로필 페이지 개수 제거, 비밀후원 전달, 채널 후원 메시지 100자 제한, `OO캔을 후원했습니다.` 문구 강조, 라이브룸 채팅 후원과 동일한 배경색 규칙을 적용했다.
+ - 실행 명령: `./gradlew --no-daemon :app:assembleDebug`
+ - 결과: 성공(BUILD SUCCESSFUL), 디버그 빌드 정상 완료.
+ - 실행 명령: `./gradlew --no-daemon :app:testDebugUnitTest`
+ - 결과: 성공(BUILD SUCCESSFUL), 단위 테스트 통과.
+ - 참고: 일반 daemon 모드에서 Kotlin incremental cache 충돌로 실패가 발생해 `--no-daemon`으로 재검증했다.
+
+- 2026-02-25 (힌트 최대 글자수/국제화 및 중복 문구 수정)
+ - 무엇/왜/어떻게: 채널 후원 UI에서는 메시지 힌트를 최대 100자로 표시하도록 변경하고(라이브는 기존 1000 유지), 힌트 문자열을 `%d` 포맷 기반 국제화 리소스로 분리했다. 또한 채널 후원 리스트 문구는 서버 `message` 원문을 그대로 표시하도록 바꿔 `OO캔을 후원했습니다.` 중복이 생기지 않게 수정하고, 원문 안의 `OO캔` 구간만 색상 강조하도록 반영했다.
+ - 실행 명령: `./gradlew --no-daemon :app:testDebugUnitTest`
+ - 결과: 성공(BUILD SUCCESSFUL), 단위 테스트 통과.
+ - 실행 명령: `./gradlew --no-daemon :app:assembleDebug`
+ - 결과: 성공(BUILD SUCCESSFUL), 디버그 빌드 정상 완료.
+ - 참고: 1회 실행에서 리소스 패키징 일시 오류(`NoSuchFileException`)가 있었으나 재실행 시 정상 통과했다.
+
+- 2026-02-25 (채널 후원 아이템 길이 제한/전체보기 터치 동작)
+ - 무엇/왜/어떻게: 채널 후원 아이템에서 긴 메시지로 인한 UI 깨짐을 막기 위해 `message`를 최대 30자 + `...`로 표시하도록 변경했다. 크리에이터 채널 페이지 아이템에는 터치 이벤트를 추가하지 않았고, 채널 후원 전체보기 페이지 아이템은 터치 시 전체 `message`를 다이얼로그로 확인할 수 있게 적용했다.
+ - 실행 명령: `./gradlew --no-daemon :app:assembleDebug`
+ - 결과: 성공(BUILD SUCCESSFUL), 디버그 빌드 정상 완료.
+ - 실행 명령: `./gradlew --no-daemon :app:testDebugUnitTest`
+ - 결과: 성공(BUILD SUCCESSFUL), 단위 테스트 통과.
+ - 참고: 테스트/빌드를 병렬 실행한 1회에서 매니페스트 입력 파일 검증 오류가 발생해 테스트를 단독 재실행하여 통과 확인했다.
+
+- 2026-02-25 (전체보기 아이템 확장 방식 변경)
+ - 무엇/왜/어떻게: 채널 후원 전체 리스트 페이지에서 말줄임표가 붙은 텍스트를 터치했을 때 AlertDialog를 띄우지 않고, 해당 아이템 내부 텍스트를 전체 내용으로 확장해서 표시하도록 변경했다. 크리에이터 채널 페이지는 기존처럼 터치 이벤트를 추가하지 않았다.
+ - 실행 명령: `./gradlew --no-daemon :app:testDebugUnitTest`
+ - 결과: 성공(BUILD SUCCESSFUL), 단위 테스트 통과.
+ - 실행 명령: `./gradlew --no-daemon :app:assembleDebug`
+ - 결과: 성공(BUILD SUCCESSFUL), 디버그 빌드 정상 완료.
+ - 참고: 테스트/빌드 병렬 실행 시 1회 manifest 중간 산출물 누락 오류가 발생해 각각 재실행하여 최종 성공을 확인했다.
+
+- 2026-02-25 (채널 후원 시간 표시 보정)
+ - 무엇/왜/어떻게: 채널 후원 아이템의 상대 시간 표시가 커뮤니티 포스트와 다르게 계산되던 문제를 수정하기 위해 `GetCommunityPostListResponse.relativeTimeText`와 동일한 계산 규칙(방금 전/분/시간/일/개월/년, UTC 파싱 및 로컬 타임존 기준 계산)으로 동기화했다.
+ - 실행 명령: `./gradlew --no-daemon :app:testDebugUnitTest`
+ - 결과: 성공(BUILD SUCCESSFUL), 단위 테스트 통과.
+ - 실행 명령: `./gradlew --no-daemon :app:assembleDebug`
+ - 결과: 성공(BUILD SUCCESSFUL), 디버그 빌드 정상 완료.