feat(home): 추천 시간과 프로필 표시를 보완한다

This commit is contained in:
2026-06-05 22:01:16 +09:00
parent 7f417c3a3f
commit 58e69be510
6 changed files with 128 additions and 22 deletions

View File

@@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.common.ToastMessage import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.databinding.FragmentV2MainHomeBinding import kr.co.vividnext.sodalive.databinding.FragmentV2MainHomeBinding
import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding
@@ -27,7 +28,9 @@ import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreato
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveUiModel
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostUiModel
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorUiModel
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentDebutCreatorSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentDebutCreatorSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationUiState import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationUiState
import kr.co.vividnext.sodalive.v2.main.home.model.visibleHomeGenreCreatorGroups import kr.co.vividnext.sodalive.v2.main.home.model.visibleHomeGenreCreatorGroups
@@ -179,7 +182,7 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private fun bindRecentActivitySection(section: HomeRecommendationRecentlyActiveCreatorSection) { private fun bindRecentActivitySection(section: HomeRecommendationRecentlyActiveCreatorSection) {
binding.llHomeRecentActivitySection.visibility = section.items.toSectionVisibility() binding.llHomeRecentActivitySection.visibility = section.items.toSectionVisibility()
recentActivityCreatorAdapter.submitItems(section.items) recentActivityCreatorAdapter.submitItems(section.items.map { it.withRelativeActivityAt() })
} }
private fun bindRecentDebutSection(section: HomeRecommendationRecentDebutCreatorSection) { private fun bindRecentDebutSection(section: HomeRecommendationRecentDebutCreatorSection) {
@@ -210,7 +213,23 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private fun bindPopularCommunitySection(section: HomeRecommendationPopularCommunityPostSection) { private fun bindPopularCommunitySection(section: HomeRecommendationPopularCommunityPostSection) {
binding.llHomePopularCommunitySection.visibility = section.items.toSectionVisibility() binding.llHomePopularCommunitySection.visibility = section.items.toSectionVisibility()
popularCommunityAdapter.submitSection(section) popularCommunityAdapter.submitSection(
HomeRecommendationPopularCommunityPostSection(section.items.map { it.withRelativeCreatedAt() })
)
}
private fun HomeRecommendationRecentlyActiveCreatorUiModel.withRelativeActivityAt():
HomeRecommendationRecentlyActiveCreatorUiModel {
return copy(activityAt = formatUtcRelativeTimeText(requireContext(), activityAt))
}
private fun HomeRecommendationPopularCommunityPostUiModel.withRelativeCreatedAt():
HomeRecommendationPopularCommunityPostUiModel {
return copy(
item = item.copy(
createdAtText = formatUtcRelativeTimeText(requireContext(), item.createdAtText)
)
)
} }
private fun setUpSectionTitles() { private fun setUpSectionTitles() {

View File

@@ -8,7 +8,6 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCreatorUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCreatorUiModel
class HomeCheerCreatorAdapter( class HomeCheerCreatorAdapter(
@@ -111,13 +110,8 @@ class HomeCheerCreatorAdapter(
topMargin = profileView.resources.getDimensionPixelSize(R.dimen.spacing_14) topMargin = profileView.resources.getDimensionPixelSize(R.dimen.spacing_14)
} }
} }
profileView.findViewById<ImageView>(R.id.iv_home_genre_creator_profile).apply { profileView.findViewById<ImageView>(R.id.iv_home_genre_creator_profile)
if (creator.profileImage.isBlank()) { .loadHomeCreatorProfileImage(creator.profileImage)
setImageDrawable(null)
} else {
loadUrl(creator.profileImage)
}
}
profileView.findViewById<TextView>(R.id.tv_home_genre_creator_profile_nickname).text = creator.nickname profileView.findViewById<TextView>(R.id.tv_home_genre_creator_profile_nickname).text = creator.nickname
profileView.setOnClickListener { onCreatorClick(creator) } profileView.setOnClickListener { onCreatorClick(creator) }
creatorGrid.addView(profileView) creatorGrid.addView(profileView)

View File

@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.v2.main.home.ui
import android.widget.ImageView
import coil.transform.CircleCropTransformation
import coil.transform.Transformation
import kr.co.vividnext.sodalive.extensions.loadUrl
fun homeCreatorProfileImageTransformations(): List<Transformation> = listOf(CircleCropTransformation())
fun ImageView.loadHomeCreatorProfileImage(url: String?) {
if (url.isNullOrBlank()) {
setImageDrawable(null)
} else {
loadUrl(url) {
transformations(homeCreatorProfileImageTransformations())
}
}
}

View File

@@ -8,7 +8,6 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCreatorUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCreatorUiModel
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorGroupUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorGroupUiModel
@@ -109,13 +108,8 @@ class HomeGenreCreatorAdapter(
topMargin = profileView.resources.getDimensionPixelSize(R.dimen.spacing_14) topMargin = profileView.resources.getDimensionPixelSize(R.dimen.spacing_14)
} }
} }
profileView.findViewById<ImageView>(R.id.iv_home_genre_creator_profile).apply { profileView.findViewById<ImageView>(R.id.iv_home_genre_creator_profile)
if (creator.profileImage.isBlank()) { .loadHomeCreatorProfileImage(creator.profileImage)
setImageDrawable(null)
} else {
loadUrl(creator.profileImage)
}
}
profileView.findViewById<TextView>(R.id.tv_home_genre_creator_profile_nickname).text = creator.nickname profileView.findViewById<TextView>(R.id.tv_home_genre_creator_profile_nickname).text = creator.nickname
profileView.setOnClickListener { onCreatorClick(creator) } profileView.setOnClickListener { onCreatorClick(creator) }
creatorGrid.addView(profileView) creatorGrid.addView(profileView)

View File

@@ -7,7 +7,6 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorUiModel
class HomeRecentActivityCreatorAdapter( class HomeRecentActivityCreatorAdapter(
@@ -42,7 +41,7 @@ class HomeRecentActivityCreatorAdapter(
private val nicknameText = itemView.findViewById<TextView>(R.id.tv_home_recent_activity_nickname) private val nicknameText = itemView.findViewById<TextView>(R.id.tv_home_recent_activity_nickname)
fun bind(item: HomeRecommendationRecentlyActiveCreatorUiModel) { fun bind(item: HomeRecommendationRecentlyActiveCreatorUiModel) {
profileImage.loadUrl(item.profileImage) profileImage.loadHomeCreatorProfileImage(item.profileImage)
item.activityLabelResId?.let { labelResId -> item.activityLabelResId?.let { labelResId ->
activityTypeText.setText(labelResId) activityTypeText.setText(labelResId)
activityTypeText.visibility = View.VISIBLE activityTypeText.visibility = View.VISIBLE

View File

@@ -15,12 +15,14 @@ import android.widget.GridLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import coil.transform.CircleCropTransformation
import androidx.core.widget.NestedScrollView import androidx.core.widget.NestedScrollView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.settings.event.EventItem import kr.co.vividnext.sodalive.settings.event.EventItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeActiveCreatorItem import kr.co.vividnext.sodalive.v2.main.home.data.HomeActiveCreatorItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeBannerItem import kr.co.vividnext.sodalive.v2.main.home.data.HomeBannerItem
@@ -51,6 +53,7 @@ import kr.co.vividnext.sodalive.v2.main.home.ui.HomeGenreCreatorAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeLiveAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeLiveAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomePopularCommunityAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomePopularCommunityAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeRecentDebutCreatorAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeRecentDebutCreatorAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.homeCreatorProfileImageTransformations
import kr.co.vividnext.sodalive.v2.widget.AudioContentCardView import kr.co.vividnext.sodalive.v2.widget.AudioContentCardView
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
import kr.co.vividnext.sodalive.v2.widget.TextTabBarView import kr.co.vividnext.sodalive.v2.widget.TextTabBarView
@@ -68,6 +71,7 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import java.util.Locale import java.util.Locale
import java.util.TimeZone
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class) @Config(sdk = [28], application = Application::class)
@@ -729,6 +733,47 @@ class HomeMainFragmentLayoutTest {
assertEquals("https://example.com/audio.m4a", item.toUiModel().item.audioUrl) assertEquals("https://example.com/audio.m4a", item.toUiModel().item.audioUrl)
} }
@Test
fun `home relative time formatter converts active creator utc timestamp to relative time text`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val defaultTimeZone = TimeZone.getDefault()
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
try {
val oneHourAgoUtc = System.currentTimeMillis() - 60 * 60_000L
assertEquals(
context.getString(R.string.character_comment_time_hours, 1),
formatUtcRelativeTimeText(context, oneHourAgoUtc.toString())
)
} finally {
TimeZone.setDefault(defaultTimeZone)
}
}
@Test
fun `home relative time formatter converts popular community createdAt to relative time text`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val defaultTimeZone = TimeZone.getDefault()
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
try {
val twoMinutesAgoUtc = System.currentTimeMillis() - 2 * 60_000L
assertEquals(
context.getString(R.string.character_comment_time_minutes, 2),
formatUtcRelativeTimeText(context, twoMinutesAgoUtc.toString())
)
} finally {
TimeZone.setDefault(defaultTimeZone)
}
}
@Test
fun `home creator profile image loading uses circle crop transformation`() {
val transformations = homeCreatorProfileImageTransformations()
assertTrue(transformations.single() is CircleCropTransformation)
}
@Test @Test
fun `home recommendation mapper uses changed response fields`() { fun `home recommendation mapper uses changed response fields`() {
val live = HomeLiveItem( val live = HomeLiveItem(
@@ -1069,6 +1114,24 @@ class HomeMainFragmentLayoutTest {
assertTrue(source.contains("followCreators(genreSectionKey")) assertTrue(source.contains("followCreators(genreSectionKey"))
} }
@Test
fun `home recommendation viewmodel does not keep android context`() {
val viewModelSource = homeRecommendationViewModelSource()
val appDiSource = appDiSource()
assertFalse(viewModelSource.contains("android.content.Context"))
assertFalse(viewModelSource.contains("private val context"))
assertTrue(appDiSource.contains("viewModel { HomeRecommendationViewModel(get()) }"))
}
@Test
fun `home main fragment formats recommendation timestamps at ui binding boundary`() {
val source = homeMainFragmentSource()
assertTrue(source.contains("formatUtcRelativeTimeText(requireContext(), activityAt)"))
assertTrue(source.contains("formatUtcRelativeTimeText(requireContext(), item.createdAtText)"))
}
private fun TextView.clickableSpanCount(): Int { private fun TextView.clickableSpanCount(): Int {
val spanned = text as? Spanned ?: return 0 val spanned = text as? Spanned ?: return 0
return spanned.getSpans(0, spanned.length, ClickableSpan::class.java).size return spanned.getSpans(0, spanned.length, ClickableSpan::class.java).size
@@ -1087,6 +1150,22 @@ class HomeMainFragmentLayoutTest {
).readText() ).readText()
} }
private fun homeRecommendationViewModelSource(): String {
val projectRoot = java.io.File("..").canonicalFile
return java.io.File(
projectRoot,
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeRecommendationViewModel.kt"
).readText()
}
private fun appDiSource(): String {
val projectRoot = java.io.File("..").canonicalFile
return java.io.File(
projectRoot,
"app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt"
).readText()
}
private fun inflateView(layoutResId: Int): View { private fun inflateView(layoutResId: Int): View {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
return LayoutInflater.from(context).inflate(layoutResId, null, false) return LayoutInflater.from(context).inflate(layoutResId, null, false)
@@ -1155,7 +1234,10 @@ class HomeMainFragmentLayoutTest {
) )
} }
private fun popularCommunityData(audioUrl: String?): HomePopularCommunityPostItem { private fun popularCommunityData(
audioUrl: String?,
createdAt: String = "2분 전"
): HomePopularCommunityPostItem {
return HomePopularCommunityPostItem( return HomePopularCommunityPostItem(
postId = 1L, postId = 1L,
creatorId = 1L, creatorId = 1L,
@@ -1165,7 +1247,7 @@ class HomeMainFragmentLayoutTest {
audioUrl = audioUrl, audioUrl = audioUrl,
content = "본문", content = "본문",
price = 0, price = 0,
createdAt = "2분 전", createdAt = createdAt,
likeCount = 6L, likeCount = 6L,
commentCount = 5L, commentCount = 5L,
existOrdered = false existOrdered = false