From 58e69be51007479c1faf3545e497f28c1f16d19a Mon Sep 17 00:00:00 2001 From: klaus Date: Fri, 5 Jun 2026 22:01:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EC=B6=94=EC=B2=9C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EA=B3=BC=20=ED=94=84=EB=A1=9C=ED=95=84=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EB=A5=BC=20=EB=B3=B4=EC=99=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/v2/main/HomeMainFragment.kt | 23 ++++- .../main/home/ui/HomeCheerCreatorAdapter.kt | 10 +-- .../home/ui/HomeCreatorProfileImageLoader.kt | 18 ++++ .../main/home/ui/HomeGenreCreatorAdapter.kt | 10 +-- .../ui/HomeRecentActivityCreatorAdapter.kt | 3 +- .../main/home/HomeMainFragmentLayoutTest.kt | 86 ++++++++++++++++++- 6 files changed, 128 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeCreatorProfileImageLoader.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt index 05dd42aa..6d6b4df7 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt @@ -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.common.Constants 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.databinding.FragmentV2MainHomeBinding 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.HomeRecommendationLiveUiModel 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.HomeRecommendationRecentlyActiveCreatorUiModel 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.visibleHomeGenreCreatorGroups @@ -179,7 +182,7 @@ class HomeMainFragment : BaseFragment( private fun bindRecentActivitySection(section: HomeRecommendationRecentlyActiveCreatorSection) { binding.llHomeRecentActivitySection.visibility = section.items.toSectionVisibility() - recentActivityCreatorAdapter.submitItems(section.items) + recentActivityCreatorAdapter.submitItems(section.items.map { it.withRelativeActivityAt() }) } private fun bindRecentDebutSection(section: HomeRecommendationRecentDebutCreatorSection) { @@ -210,7 +213,23 @@ class HomeMainFragment : BaseFragment( private fun bindPopularCommunitySection(section: HomeRecommendationPopularCommunityPostSection) { 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() { diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeCheerCreatorAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeCheerCreatorAdapter.kt index dfdf1b11..01358031 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeCheerCreatorAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeCheerCreatorAdapter.kt @@ -8,7 +8,6 @@ import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import kr.co.vividnext.sodalive.R -import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCreatorUiModel class HomeCheerCreatorAdapter( @@ -111,13 +110,8 @@ class HomeCheerCreatorAdapter( topMargin = profileView.resources.getDimensionPixelSize(R.dimen.spacing_14) } } - profileView.findViewById(R.id.iv_home_genre_creator_profile).apply { - if (creator.profileImage.isBlank()) { - setImageDrawable(null) - } else { - loadUrl(creator.profileImage) - } - } + profileView.findViewById(R.id.iv_home_genre_creator_profile) + .loadHomeCreatorProfileImage(creator.profileImage) profileView.findViewById(R.id.tv_home_genre_creator_profile_nickname).text = creator.nickname profileView.setOnClickListener { onCreatorClick(creator) } creatorGrid.addView(profileView) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeCreatorProfileImageLoader.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeCreatorProfileImageLoader.kt new file mode 100644 index 00000000..571925a5 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeCreatorProfileImageLoader.kt @@ -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 = listOf(CircleCropTransformation()) + +fun ImageView.loadHomeCreatorProfileImage(url: String?) { + if (url.isNullOrBlank()) { + setImageDrawable(null) + } else { + loadUrl(url) { + transformations(homeCreatorProfileImageTransformations()) + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeGenreCreatorAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeGenreCreatorAdapter.kt index c6db9f3b..53c09b4c 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeGenreCreatorAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeGenreCreatorAdapter.kt @@ -8,7 +8,6 @@ import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.RecyclerView 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.HomeRecommendationGenreCreatorGroupUiModel @@ -109,13 +108,8 @@ class HomeGenreCreatorAdapter( topMargin = profileView.resources.getDimensionPixelSize(R.dimen.spacing_14) } } - profileView.findViewById(R.id.iv_home_genre_creator_profile).apply { - if (creator.profileImage.isBlank()) { - setImageDrawable(null) - } else { - loadUrl(creator.profileImage) - } - } + profileView.findViewById(R.id.iv_home_genre_creator_profile) + .loadHomeCreatorProfileImage(creator.profileImage) profileView.findViewById(R.id.tv_home_genre_creator_profile_nickname).text = creator.nickname profileView.setOnClickListener { onCreatorClick(creator) } creatorGrid.addView(profileView) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeRecentActivityCreatorAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeRecentActivityCreatorAdapter.kt index dd5d13ca..c702dbad 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeRecentActivityCreatorAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeRecentActivityCreatorAdapter.kt @@ -7,7 +7,6 @@ import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import kr.co.vividnext.sodalive.R -import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorUiModel class HomeRecentActivityCreatorAdapter( @@ -42,7 +41,7 @@ class HomeRecentActivityCreatorAdapter( private val nicknameText = itemView.findViewById(R.id.tv_home_recent_activity_nickname) fun bind(item: HomeRecommendationRecentlyActiveCreatorUiModel) { - profileImage.loadUrl(item.profileImage) + profileImage.loadHomeCreatorProfileImage(item.profileImage) item.activityLabelResId?.let { labelResId -> activityTypeText.setText(labelResId) activityTypeText.visibility = View.VISIBLE diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt index 1b6643e8..745ce257 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt @@ -15,12 +15,14 @@ import android.widget.GridLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import coil.transform.CircleCropTransformation import androidx.core.widget.NestedScrollView import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ApplicationProvider 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.v2.main.home.data.HomeActiveCreatorItem 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.HomePopularCommunityAdapter 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.AudioContentTag import kr.co.vividnext.sodalive.v2.widget.TextTabBarView @@ -68,6 +71,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import java.util.Locale +import java.util.TimeZone @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], application = Application::class) @@ -729,6 +733,47 @@ class HomeMainFragmentLayoutTest { 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() + 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() + 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 fun `home recommendation mapper uses changed response fields`() { val live = HomeLiveItem( @@ -1069,6 +1114,24 @@ class HomeMainFragmentLayoutTest { 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 { val spanned = text as? Spanned ?: return 0 return spanned.getSpans(0, spanned.length, ClickableSpan::class.java).size @@ -1087,6 +1150,22 @@ class HomeMainFragmentLayoutTest { ).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 { val context = ApplicationProvider.getApplicationContext() 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( postId = 1L, creatorId = 1L, @@ -1165,7 +1247,7 @@ class HomeMainFragmentLayoutTest { audioUrl = audioUrl, content = "본문", price = 0, - createdAt = "2분 전", + createdAt = createdAt, likeCount = 6L, commentCount = 5L, existOrdered = false