From 092fc67b0b0a6271724109aa50fc12f5a1e99a43 Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 25 Feb 2026 20:57:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(profile):=20=EC=B1=84=EB=84=90=20=ED=9B=84?= =?UTF-8?q?=EC=9B=90=20=EC=98=81=EC=97=AD=EA=B3=BC=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=ED=9D=90=EB=A6=84=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 1 + .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 2 + .../sodalive/explorer/ExplorerApi.kt | 14 +++ .../sodalive/explorer/ExplorerRepository.kt | 18 ++++ .../profile/GetCreatorProfileResponse.kt | 5 +- .../explorer/profile/UserProfileActivity.kt | 86 +++++++++++++++++ .../explorer/profile/UserProfileViewModel.kt | 89 +++++++++++++++++ .../ChannelDonationTimeFormatter.kt | 93 ++++++++++++++++++ .../ChannelDonationUiFormatter.kt | 56 +++++++++++ .../GetChannelDonationListResponse.kt | 22 +++++ .../PostChannelDonationRequest.kt | 13 +++ .../UserProfileChannelDonationAdapter.kt | 51 ++++++++++ .../UserProfileChannelDonationAllAdapter.kt | 61 ++++++++++++ ...erProfileChannelDonationAllViewActivity.kt | 95 +++++++++++++++++++ .../UserProfileChannelDonationAllViewModel.kt | 66 +++++++++++++ .../room/donation/LiveRoomDonationDialog.kt | 20 +++- .../drawable/bg_round_corner_16_525252.xml | 8 ++ .../main/res/layout/activity_user_profile.xml | 9 ++ ...vity_user_profile_channel_donation_all.xml | 70 ++++++++++++++ .../item_user_profile_channel_donation.xml | 56 +++++++++++ ...item_user_profile_channel_donation_all.xml | 56 +++++++++++ .../layout_user_profile_channel_donation.xml | 74 +++++++++++++++ app/src/main/res/values-en/strings.xml | 6 ++ app/src/main/res/values-ja/strings.xml | 6 ++ app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/strings.xml | 6 ++ docs/20260225_채널후원영역및전체보기구현.md | 70 ++++++++++++++ 27 files changed, 1049 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/ChannelDonationTimeFormatter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/ChannelDonationUiFormatter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/GetChannelDonationListResponse.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/PostChannelDonationRequest.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAdapter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAllAdapter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAllViewActivity.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/channel_donation/UserProfileChannelDonationAllViewModel.kt create mode 100644 app/src/main/res/drawable/bg_round_corner_16_525252.xml create mode 100644 app/src/main/res/layout/activity_user_profile_channel_donation_all.xml create mode 100644 app/src/main/res/layout/item_user_profile_channel_donation.xml create mode 100644 app/src/main/res/layout/item_user_profile_channel_donation_all.xml create mode 100644 app/src/main/res/layout/layout_user_profile_channel_donation.xml create mode 100644 docs/20260225_채널후원영역및전체보기구현.md 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), 디버그 빌드 정상 완료.