Compare commits

..

45 Commits

Author SHA1 Message Date
926e7eabd3 chore(creator-ranking): 기존 카드 뷰를 제거한다 2026-06-30 02:44:36 +09:00
7c7aa0f442 refactor(creator-ranking): 어댑터 뷰 타입을 정리한다 2026-06-30 02:44:19 +09:00
86be8e4af1 refactor(creator-ranking): 하위 행 뷰를 추가한다 2026-06-30 02:44:04 +09:00
d5cc5df704 refactor(creator-ranking): 상위 카드 뷰를 추가한다 2026-06-30 02:43:54 +09:00
aac09c14bc refactor(creator-ranking): 배치 모델을 단순화한다 2026-06-30 02:43:39 +09:00
566f76df6a docs(test): UI 테스트 기준을 보완한다 2026-06-30 02:43:10 +09:00
3ce5cd0e3b docs(creator-ranking): 뷰 재작성 작업을 기록한다 2026-06-30 02:42:56 +09:00
f3f175fcc6 docs(creator-ranking): 뷰 재작성 PRD를 보완한다 2026-06-30 02:42:46 +09:00
16995e5a0f fix(content): 콘텐츠 랭킹 순위 표시를 보완한다 2026-06-30 02:42:31 +09:00
43c8762df7 docs(content): 콘텐츠 랭킹 작업 기록을 추가한다 2026-06-30 02:40:42 +09:00
f27668e038 docs(content): 콘텐츠 랭킹 PRD를 보완한다 2026-06-30 02:40:31 +09:00
3e14cf4320 test(content): FIRST 오디오 전체보기 테스트를 정리한다 2026-06-29 17:40:39 +09:00
78573d0e81 fix(content): FIRST 오디오 전체보기 타입을 제거한다 2026-06-29 17:40:16 +09:00
bc5814b7c5 test(home): FIRST 오디오 홈 테스트를 정리한다 2026-06-29 17:40:00 +09:00
ed2e478291 chore(home): FIRST 오디오 문구 리소스를 제거한다 2026-06-29 17:39:25 +09:00
3ba46916b1 fix(home): FIRST 오디오 섹션 UI를 제거한다 2026-06-29 17:39:14 +09:00
9d2ccd975f fix(home): FIRST 오디오 응답 매핑을 제거한다 2026-06-29 17:38:58 +09:00
acbf9bb013 docs(content): FIRST 오디오 전체보기 제거를 기록한다 2026-06-29 17:38:35 +09:00
9fce374842 docs(home): FIRST 오디오 제거 요구사항을 기록한다 2026-06-29 17:38:10 +09:00
08c7bcf4f2 fix(home): 최근 데뷔 카드 clipping을 보완한다 2026-06-29 16:41:32 +09:00
93a5d1293c docs(home): 최근 데뷔 요구사항을 기록한다 2026-06-29 16:41:15 +09:00
5578d59e1f feat(release): release 서명 설정을 추가한다 2026-06-29 16:40:58 +09:00
f1e3b5a49e docs(release): release 서명 요구사항을 기록한다 2026-06-29 16:40:18 +09:00
b9d474075f versionCode 239, versionName 1.55.0 2026-06-27 09:47:53 +09:00
37200773aa docs(content): Phase 8 검증을 기록한다 2026-06-27 09:20:49 +09:00
bbe9eca97b docs(content): Phase 7 검증을 기록한다 2026-06-27 09:04:49 +09:00
77a201e139 feat(content): New&Hot 전체보기 이동을 연결한다 2026-06-27 09:04:41 +09:00
4c7887055d feat(home): FIRST 오디오 전체보기 이동을 연결한다 2026-06-27 09:04:31 +09:00
0d7512fee2 docs(content): 전체보기 구현 검증을 기록한다 2026-06-27 08:30:22 +09:00
0da2a3f85f chore(content): 미사용 전체보기 아이콘을 제거한다 2026-06-27 08:30:14 +09:00
9ab5101c48 feat(content): 전체보기 화면 등록을 추가한다 2026-06-27 08:30:06 +09:00
0906a21c6d feat(content): 전체보기 화면 UI를 추가한다 2026-06-27 08:29:58 +09:00
0134942172 feat(content): 전체보기 ViewModel을 추가한다 2026-06-27 08:29:44 +09:00
0a4b88fabe feat(content): 전체보기 UI 모델 매핑을 추가한다 2026-06-27 08:29:19 +09:00
b772e5416e feat(content): 전체보기 API 계층을 추가한다 2026-06-27 08:29:07 +09:00
1416db105e docs(content): 전체 탭 이동 검증을 기록한다 2026-06-27 06:32:49 +09:00
b6cc37fe50 feat(content): 추천 섹션 전체 탭 이동을 연결한다 2026-06-27 06:32:41 +09:00
43e90ed47b feat(content): 전체 탭 외부 선택을 추가한다 2026-06-27 06:32:31 +09:00
5099795d29 docs(content): chevron 표시 검증을 기록한다 2026-06-27 06:02:48 +09:00
73dc939cd3 feat(content): 추천 섹션 chevron 표시를 적용한다 2026-06-27 06:02:38 +09:00
1bd0f369ee test(home): FIRST 오디오 chevron 검증을 추가한다 2026-06-27 06:02:26 +09:00
8da39949e5 docs(content): 콘텐츠 전체보기 문서를 정리한다 2026-06-27 05:29:35 +09:00
0454980fae fix(main): 대화 탭 로그인 가드를 적용한다 2026-06-27 04:47:57 +09:00
0679dc7deb docs(channel): 탭 전환 스크롤 요구사항을 기록한다 2026-06-27 04:28:44 +09:00
0e07ed0b5a fix(channel): 탭 전환 자동 스크롤을 제거한다 2026-06-27 04:28:35 +09:00
76 changed files with 3944 additions and 1478 deletions

8
.gitignore vendored
View File

@@ -64,9 +64,11 @@ captures/
.idea/deploymentTargetSelector.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
*.jks
*.keystore
*.p12
*.pem
*.key
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild

View File

@@ -1,4 +1,6 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Locale
import java.util.Properties
plugins {
id 'com.android.application'
@@ -13,6 +15,27 @@ plugins {
id 'com.google.firebase.crashlytics'
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withInputStream { stream ->
localProperties.load(stream)
}
}
def releaseSigningPropertyKeys = [
'RELEASE_STORE_FILE',
'RELEASE_STORE_PASSWORD',
'RELEASE_KEY_ALIAS',
'RELEASE_KEY_PASSWORD'
]
def releaseSigningProperty = { key ->
localProperties.getProperty(key)?.trim()
}
def missingReleaseSigningProperties = {
releaseSigningPropertyKeys.findAll { key -> !releaseSigningProperty(key) }
}
android {
namespace 'kr.co.vividnext.sodalive'
compileSdk = 35
@@ -63,13 +86,30 @@ android {
applicationId "kr.co.vividnext.sodalive"
minSdk 23
targetSdk 35
versionCode 237
versionName "1.54.1"
versionCode 239
versionName "1.55.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
release {
def releaseStoreFile = releaseSigningProperty('RELEASE_STORE_FILE')
def releaseStorePassword = releaseSigningProperty('RELEASE_STORE_PASSWORD')
def releaseKeyAlias = releaseSigningProperty('RELEASE_KEY_ALIAS')
def releaseKeyPassword = releaseSigningProperty('RELEASE_KEY_PASSWORD')
if (missingReleaseSigningProperties().isEmpty()) {
storeFile rootProject.file(releaseStoreFile)
storePassword releaseStorePassword
keyAlias releaseKeyAlias
keyPassword releaseKeyPassword
}
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
@@ -172,6 +212,36 @@ android {
}
}
gradle.taskGraph.whenReady { taskGraph ->
def requiresReleaseSigning = taskGraph.allTasks.any { task ->
def taskName = task.name.toLowerCase(Locale.US)
taskName.contains('release') && (
taskName.startsWith('assemble') ||
taskName.startsWith('bundle') ||
taskName.startsWith('install') ||
taskName.startsWith('package')
)
}
if (!requiresReleaseSigning) {
return
}
def missingProperties = missingReleaseSigningProperties()
if (!missingProperties.isEmpty()) {
throw new GradleException(
"Release signing properties are missing in local.properties: ${missingProperties.join(', ')}"
)
}
def releaseStoreFile = rootProject.file(releaseSigningProperty('RELEASE_STORE_FILE'))
if (!releaseStoreFile.exists()) {
throw new GradleException(
"Release signing store file does not exist: ${releaseStoreFile}"
)
}
}
dependencies {
implementation "androidx.media:media:1.7.1"
implementation 'androidx.core:core-ktx:1.16.0'

View File

@@ -112,6 +112,7 @@
</activity>
<activity android:name=".main.MainActivity" />
<activity android:name=".v2.main.MainV2Activity" />
<activity android:name=".v2.main.content.overview.ContentOverviewActivity" />
<activity android:name=".v2.creator.channel.CreatorChannelActivity" />
<activity android:name=".v2.live.onair.HomeOnAirLiveActivity" />
<activity

View File

@@ -207,6 +207,9 @@ import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingsApi
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingsRepository
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllTabApi
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllTabRepository
import kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewViewModel
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewRepository
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewApi
import kr.co.vividnext.sodalive.v2.main.home.HomeCreatorRankingViewModel
import kr.co.vividnext.sodalive.v2.main.home.HomeFollowingViewModel
import kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModel
@@ -331,6 +334,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
single { ApiBuilder().build(get(), AudioRecommendationsApi::class.java) }
single { ApiBuilder().build(get(), AudioRankingsApi::class.java) }
single { ApiBuilder().build(get(), MainContentAllTabApi::class.java) }
single { ApiBuilder().build(get(), ContentOverviewApi::class.java) }
single { ApiBuilder().build(get(), HomeCreatorRankingApi::class.java) }
single { ApiBuilder().build(get(), HomeFollowingApi::class.java) }
single { ApiBuilder().build(get(), HomeRecommendationApi::class.java) }
@@ -436,6 +440,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { ChatMainViewModel(get()) }
viewModel { DmChatRoomViewModel(get()) }
viewModel { ContentAllTabViewModel(get()) }
viewModel { ContentOverviewViewModel(get()) }
viewModel { ContentMainViewModel(get()) }
viewModel { ContentRankingViewModel(get()) }
viewModel { HomeCreatorRankingViewModel(get()) }
@@ -504,6 +509,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { AudioRecommendationsRepository(get()) }
factory { AudioRankingsRepository(get()) }
factory { MainContentAllTabRepository(get()) }
factory { ContentOverviewRepository(get()) }
factory { HomeCreatorRankingRepository(get()) }
factory { HomeFollowingRepository(get()) }
factory { HomeRecommendationRepository(get()) }

View File

@@ -100,7 +100,6 @@ class CreatorChannelActivity :
private var statusBarHeight: Int = 0
private var tabLayoutMediator: TabLayoutMediator? = null
private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null
private var lastSelectedCreatorChannelTabPosition: Int? = null
private var isOwnerFabExpanded: Boolean = false
private var isOwnerFabAnimating: Boolean = false
private var isDonationFloatingButtonVisible: Boolean = false
@@ -343,17 +342,6 @@ class CreatorChannelActivity :
binding.tvTitleNickname.isVisible = shouldUseBlackTitleBar
}
private fun adjustCreatorChannelStickyAnchorOnTabSelected(position: Int) {
val previousPosition = lastSelectedCreatorChannelTabPosition
lastSelectedCreatorChannelTabPosition = position
if (previousPosition == null || previousPosition == position) return
val stickyScrollY = calculateCreatorChannelStickyScrollY()
if (binding.nestedScrollView.scrollY < stickyScrollY) {
binding.nestedScrollView.scrollTo(0, stickyScrollY)
}
}
private fun calculateCreatorChannelStickyScrollY(): Int {
val stickyTop = CreatorChannelScrollState.calculateStickyTop(statusBarHeight, baseTitleBarHeight)
return (binding.headerContainer.height - stickyTop).coerceAtLeast(0)
@@ -415,10 +403,8 @@ class CreatorChannelActivity :
}.also {
it.attach()
}
lastSelectedCreatorChannelTabPosition = binding.viewPager.currentItem
val callback = object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
adjustCreatorChannelStickyAnchorOnTabSelected(position)
if (position != CreatorChannelTab.Home.ordinal) {
collapseOwnerFab(animate = false)
}

View File

@@ -47,11 +47,13 @@ import kr.co.vividnext.sodalive.mypage.MyPageFragment
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
import kr.co.vividnext.sodalive.settings.notification.NotificationSettingsDialog
import kr.co.vividnext.sodalive.user.login.LoginActivity
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragment
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomFilter
import kr.co.vividnext.sodalive.v2.main.content.ContentMainFragment
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
import kr.co.vividnext.sodalive.v2.main.home.HomeMainFragment
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
@@ -147,12 +149,12 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
setupBottomNavigation()
if (intent.hasExtra(EXTRA_CHAT_FILTER)) {
viewModel.clickTab(MainV2Tab.CHAT)
selectChatTabWithLoginGuard()
}
}
fun showLoginActivity() {
if (SharedPreferenceManager.token.isBlank()) {
if (!isLoggedIn()) {
val extras = intent.extras
startActivity(
Intent(applicationContext, LoginActivity::class.java).apply {
@@ -165,7 +167,17 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
}
fun openChatTab() {
viewModel.clickTab(MainV2Tab.CHAT)
selectChatTabWithLoginGuard()
}
fun openContentAllTab(type: MainContentAllType, sort: ContentSort = ContentSort.LATEST) {
if (viewModel.currentTab.value != MainV2Tab.CONTENT) {
viewModel.clickTab(MainV2Tab.CONTENT)
changeFragment(MainV2Tab.CONTENT)
}
(supportFragmentManager.findFragmentByTag(MainV2Tab.CONTENT.toString()) as? ContentMainFragment)
?.selectAllTab(type, sort)
}
private fun consumeInitialChatFilter(): ChatRoomFilter? {
@@ -182,18 +194,27 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
return
}
viewModel.clickTab(MainV2Tab.CHAT)
selectChatTabWithLoginGuard()
}
private fun setupBottomNavigation() {
binding.bottomNavigation.setOnItemSelectedListener { item ->
when (item.itemId) {
R.id.menu_main_v2_home -> viewModel.clickTab(MainV2Tab.HOME)
R.id.menu_main_v2_content -> viewModel.clickTab(MainV2Tab.CONTENT)
R.id.menu_main_v2_chat -> viewModel.clickTab(MainV2Tab.CHAT)
R.id.menu_main_v2_my -> viewModel.clickTab(MainV2Tab.MY)
R.id.menu_main_v2_home -> {
viewModel.clickTab(MainV2Tab.HOME)
true
}
R.id.menu_main_v2_content -> {
viewModel.clickTab(MainV2Tab.CONTENT)
true
}
R.id.menu_main_v2_chat -> selectChatTabWithLoginGuard()
R.id.menu_main_v2_my -> {
viewModel.clickTab(MainV2Tab.MY)
true
}
else -> false
}
true
}
binding.bottomNavigation.apply {
@@ -247,6 +268,16 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
fragmentTransaction.commitNow()
}
private fun selectChatTabWithLoginGuard(): Boolean {
if (!isLoggedIn()) {
showLoginActivity()
return false
}
viewModel.clickTab(MainV2Tab.CHAT)
return true
}
private fun observePlayerState() {
playerStateJob = lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {

View File

@@ -63,6 +63,13 @@ class ContentAllTabViewModel(
loadFirstPage(selectedType, sort, selectedDayOfWeekFor(selectedType))
}
fun selectTypeAndSort(type: MainContentAllType, sort: ContentSort) {
selectedType = type
selectedSort = sort
selectedDayOfWeek = selectedDayOfWeekFor(type)
loadFirstPage(type, sort, selectedDayOfWeek)
}
fun changeDayOfWeek(dayOfWeek: SeriesPublishedDaysOfWeek) {
if (!selectedType.usesDayOfWeekQuery()) return

View File

@@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.model.toLabelResId
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelSortPopup
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import kr.co.vividnext.sodalive.v2.main.ensureMainV2NavigationAllowed
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingType
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
@@ -47,6 +48,8 @@ import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerIntent
import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerRoute
import kr.co.vividnext.sodalive.v2.main.content.model.usesDayOfWeekQuery
import kr.co.vividnext.sodalive.v2.main.content.model.usesSeriesItems
import kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivity
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewType
import kr.co.vividnext.sodalive.v2.main.content.ui.CONTENT_ALL_GRID_SPAN_COUNT
import kr.co.vividnext.sodalive.v2.main.content.ui.CONTENT_RECOMMENDED_GRID_SPAN_COUNT
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentAllAudioCardAdapter
@@ -101,6 +104,7 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
private var hasSelectedAllTab = false
private var currentContentTab = CONTENT_TAB_RECOMMENDATION
private var currentAllTabState: MainContentAllTabUiState? = null
private var pendingAllTabSelection: ContentAllTabSelection? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -114,6 +118,7 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
setUpAdapters()
bindObservers()
contentMainViewModel.loadRecommendations()
applyPendingAllTabSelection()
}
override fun onDestroyView() {
@@ -124,6 +129,22 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
super.onDestroyView()
}
fun selectAllTab(type: MainContentAllType, sort: ContentSort = ContentSort.LATEST) {
pendingAllTabSelection = ContentAllTabSelection(type, sort)
if (view != null) {
applyPendingAllTabSelection()
}
}
private fun applyPendingAllTabSelection() {
val selection = pendingAllTabSelection ?: return
pendingAllTabSelection = null
hasSelectedAllTab = true
binding.textTabBarContent.root.selectTab(CONTENT_TAB_ALL)
showContentTab(CONTENT_TAB_ALL)
contentAllTabViewModel.selectTypeAndSort(selection.type, selection.sort)
}
private fun setUpTextTabs() {
binding.textTabBarContent.root.setMenus(
listOf(
@@ -507,18 +528,51 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
}
private fun setUpSectionTitles() {
binding.viewContentOriginalSeriesTitle.setTitle(R.string.content_recommendation_section_original_series)
binding.viewContentLatestAudioTitle.setTitle(R.string.content_recommendation_section_latest_audio)
binding.viewContentNewAndHotTitle.setTitle(R.string.content_recommendation_section_new_and_hot)
binding.viewContentFreeAudioTitle.setTitle(R.string.content_recommendation_section_free_audio)
binding.viewContentPointAudioTitle.setTitle(R.string.content_recommendation_section_point_audio)
binding.viewContentOriginalSeriesTitle.setTitle(
R.string.content_recommendation_section_original_series,
showMore = true
)
binding.viewContentLatestAudioTitle.setTitle(
R.string.content_recommendation_section_latest_audio,
showMore = true
)
binding.viewContentNewAndHotTitle.setTitle(
R.string.content_recommendation_section_new_and_hot,
showMore = true
)
binding.viewContentFreeAudioTitle.setTitle(
R.string.content_recommendation_section_free_audio,
showMore = true
)
binding.viewContentPointAudioTitle.setTitle(
R.string.content_recommendation_section_point_audio,
showMore = true
)
binding.viewContentMostCommentedAudioTitle.setTitle(R.string.content_recommendation_section_most_commented_audio)
binding.viewContentRecommendedAudioTitle.setTitle(R.string.content_recommendation_section_recommended_audio)
binding.viewContentOriginalSeriesTitle.ivSectionTitleChevron.setOnClickListener {
openContentAllTab(MainContentAllType.ORIGINAL, ContentSort.LATEST)
}
binding.viewContentLatestAudioTitle.ivSectionTitleChevron.setOnClickListener {
openContentAllTab(MainContentAllType.AUDIO, ContentSort.LATEST)
}
binding.viewContentNewAndHotTitle.ivSectionTitleChevron.setOnClickListener {
openContentOverview(ContentOverviewType.NEW_AND_HOT_AUDIO)
}
binding.viewContentFreeAudioTitle.ivSectionTitleChevron.setOnClickListener {
openContentAllTab(MainContentAllType.FREE, ContentSort.POPULAR)
}
binding.viewContentPointAudioTitle.ivSectionTitleChevron.setOnClickListener {
openContentAllTab(MainContentAllType.POINT, ContentSort.POPULAR)
}
}
private fun ViewSectionTitleBinding.setTitle(titleResId: Int) {
private fun ViewSectionTitleBinding.setTitle(
titleResId: Int,
showMore: Boolean = false
) {
tvSectionTitle.setText(titleResId)
ivSectionTitleChevron.visibility = View.GONE
ivSectionTitleChevron.visibility = if (showMore) View.VISIBLE else View.GONE
}
private fun onBannerClick(item: ContentBannerUiModel) {
@@ -528,6 +582,18 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
}
}
private fun openContentAllTab(type: MainContentAllType, sort: ContentSort) {
ensureMainV2NavigationAllowed {
(activity as? MainV2Activity)?.openContentAllTab(type, sort)
}
}
private fun openContentOverview(type: ContentOverviewType) {
ensureMainV2NavigationAllowed {
startActivity(ContentOverviewActivity.newIntent(requireContext(), type))
}
}
private fun openAudioContentDetail(item: ContentAudioCardUiModel) {
openAudioContentDetail(item.audioContentId, item.showAdultBadge)
}
@@ -626,4 +692,9 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
private const val CONTENT_TAB_RANKING = 1
private const val CONTENT_TAB_ALL = 2
}
private data class ContentAllTabSelection(
val type: MainContentAllType,
val sort: ContentSort
)
}

View File

@@ -0,0 +1,153 @@
package kr.co.vividnext.sodalive.v2.main.content.overview
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.core.view.doOnLayout
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.databinding.ActivityContentOverviewBinding
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewType
import kr.co.vividnext.sodalive.v2.main.content.overview.model.ContentOverviewUiState
import kr.co.vividnext.sodalive.v2.main.content.overview.model.toTitleResId
import kr.co.vividnext.sodalive.v2.main.content.overview.ui.ContentOverviewAdapter
import kr.co.vividnext.sodalive.v2.main.content.ui.addContentGridItemSpacing
import kr.co.vividnext.sodalive.v2.main.content.ui.calculateContentGridItemWidthPx
import org.koin.androidx.viewmodel.ext.android.viewModel
class ContentOverviewActivity : BaseActivity<ActivityContentOverviewBinding>(
ActivityContentOverviewBinding::inflate
) {
private val viewModel: ContentOverviewViewModel by viewModel()
private val overviewAdapter = ContentOverviewAdapter(::openAudioContentDetail)
private val overviewType: ContentOverviewType by lazy { intent.contentOverviewType() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.loadFirstPage(overviewType)
}
override fun setupView() {
setupTitleBar()
setupRecyclerView()
observeViewModel()
}
private fun setupTitleBar() {
binding.tvContentOverviewTitle.setText(overviewType.toTitleResId())
binding.ivContentOverviewBack.setOnClickListener { finish() }
}
private fun setupRecyclerView() {
binding.rvContentOverviewItems.adapter = overviewAdapter
binding.rvContentOverviewItems.layoutManager = GridLayoutManager(this, CONTENT_OVERVIEW_GRID_SPAN_COUNT)
binding.rvContentOverviewItems.addContentGridItemSpacing(CONTENT_OVERVIEW_GRID_SPAN_COUNT)
binding.rvContentOverviewItems.doOnLayout { updateGridItemWidth() }
binding.rvContentOverviewItems.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (!recyclerView.canScrollVertically(1)) {
viewModel.loadMore()
}
}
})
}
private fun observeViewModel() {
viewModel.isLoading.observe(this) { isLoading ->
binding.pbContentOverviewInitialLoading.visibility = if (isLoading) View.VISIBLE else View.GONE
}
viewModel.overviewStateLiveData.observe(this, ::renderState)
viewModel.toastLiveData.observe(this) { toastMessage ->
toastMessage?.let(::showToast)
}
}
private fun renderState(state: ContentOverviewUiState) {
when (state) {
is ContentOverviewUiState.Loading -> renderLoading()
is ContentOverviewUiState.Content -> renderContent(state)
is ContentOverviewUiState.Empty -> renderEmpty()
is ContentOverviewUiState.Error -> renderError()
}
}
private fun renderLoading() {
binding.rvContentOverviewItems.visibility = View.GONE
binding.tvContentOverviewEmptyError.visibility = View.GONE
binding.pbContentOverviewInitialLoading.visibility = View.VISIBLE
binding.pbContentOverviewLoadMore.visibility = View.GONE
}
private fun renderContent(state: ContentOverviewUiState.Content) {
binding.rvContentOverviewItems.visibility = View.VISIBLE
binding.tvContentOverviewEmptyError.visibility = View.GONE
binding.pbContentOverviewInitialLoading.visibility = View.GONE
binding.pbContentOverviewLoadMore.visibility = if (state.isLoadingMore) View.VISIBLE else View.GONE
overviewAdapter.submitItems(state.items)
updateGridItemWidth()
state.paginationErrorMessage?.let {
showToast(it)
viewModel.consumePaginationErrorMessage()
}
}
private fun renderEmpty() {
overviewAdapter.submitItems(emptyList())
binding.rvContentOverviewItems.visibility = View.GONE
binding.tvContentOverviewEmptyError.visibility = View.VISIBLE
binding.pbContentOverviewInitialLoading.visibility = View.GONE
binding.pbContentOverviewLoadMore.visibility = View.GONE
binding.tvContentOverviewEmptyError.setText(R.string.screen_content_all_empty)
}
private fun renderError() {
overviewAdapter.submitItems(emptyList())
binding.rvContentOverviewItems.visibility = View.GONE
binding.tvContentOverviewEmptyError.visibility = View.VISIBLE
binding.pbContentOverviewInitialLoading.visibility = View.GONE
binding.pbContentOverviewLoadMore.visibility = View.GONE
binding.tvContentOverviewEmptyError.setText(R.string.common_error_unknown)
}
private fun updateGridItemWidth() {
val widthPx = binding.rvContentOverviewItems.calculateContentGridItemWidthPx(CONTENT_OVERVIEW_GRID_SPAN_COUNT)
overviewAdapter.setGridItemWidthPx(widthPx)
}
private fun showToast(toastMessage: kr.co.vividnext.sodalive.common.ToastMessage) {
toastMessage.message?.let { message -> showToast(message) }
?: toastMessage.resId?.let { resId -> showToast(getString(resId)) }
}
private fun openAudioContentDetail(contentId: Long) {
if (contentId <= 0) return
startActivity(
Intent(this, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)
}
)
}
private fun Intent.contentOverviewType(): ContentOverviewType {
val typeName = getStringExtra(EXTRA_CONTENT_OVERVIEW_TYPE)
return ContentOverviewType.entries.firstOrNull { it.name == typeName }
?: ContentOverviewType.NEW_AND_HOT_AUDIO
}
companion object {
const val CONTENT_OVERVIEW_GRID_SPAN_COUNT = 2
private const val EXTRA_CONTENT_OVERVIEW_TYPE = "content_overview_type"
fun newIntent(context: Context, type: ContentOverviewType): Intent {
return Intent(context, ContentOverviewActivity::class.java)
.putExtra(EXTRA_CONTENT_OVERVIEW_TYPE, type.name)
}
}
}

View File

@@ -0,0 +1,146 @@
package kr.co.vividnext.sodalive.v2.main.content.overview
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.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewPageResponse
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewRepository
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewType
import kr.co.vividnext.sodalive.v2.main.content.overview.model.ContentOverviewUiState
import kr.co.vividnext.sodalive.v2.main.content.overview.model.toContent
class ContentOverviewViewModel(
private val repository: ContentOverviewRepository
) : BaseViewModel() {
private val _overviewStateLiveData = MutableLiveData<ContentOverviewUiState>()
val overviewStateLiveData: LiveData<ContentOverviewUiState>
get() = _overviewStateLiveData
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _toastLiveData = MutableLiveData<ToastMessage?>()
val toastLiveData: LiveData<ToastMessage?>
get() = _toastLiveData
private var selectedType: ContentOverviewType = ContentOverviewType.NEW_AND_HOT_AUDIO
private var requestGeneration: Int = 0
fun loadFirstPage(type: ContentOverviewType) {
selectedType = type
val generation = ++requestGeneration
_isLoading.value = true
_overviewStateLiveData.value = ContentOverviewUiState.Loading(type)
requestContents(type, FIRST_PAGE, generation) { response ->
_isLoading.value = false
val data = response.data
if (response.success && data != null) {
val content = data.toContent()
_overviewStateLiveData.value = if (content.items.isEmpty()) {
ContentOverviewUiState.Empty(content.type, content.totalCount)
} else {
content
}
} else {
showFirstPageError(type, response.message)
}
}
}
fun loadMore() {
val content = _overviewStateLiveData.value as? ContentOverviewUiState.Content ?: return
if (!content.hasNext || content.isLoadingMore) return
val generation = requestGeneration
_overviewStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
requestContents(content.type, content.page + 1, generation) { response ->
val current = _overviewStateLiveData.value as? ContentOverviewUiState.Content ?: content
val data = response.data
if (response.success && data != null) {
val mapped = data.toContent()
_overviewStateLiveData.value = current.copy(
type = mapped.type,
totalCount = mapped.totalCount,
items = current.items + mapped.items,
page = mapped.page,
size = mapped.size,
hasNext = mapped.hasNext,
isLoadingMore = false,
paginationErrorMessage = null
)
} else {
_overviewStateLiveData.value = current.copy(
isLoadingMore = false,
paginationErrorMessage = response.message
)
}
}
}
fun retry() {
loadFirstPage(selectedType)
}
fun consumePaginationErrorMessage() {
val content = _overviewStateLiveData.value as? ContentOverviewUiState.Content ?: return
if (content.paginationErrorMessage == null) return
_overviewStateLiveData.value = content.copy(paginationErrorMessage = null)
}
private fun requestContents(
type: ContentOverviewType,
page: Int,
generation: Int,
onSuccess: (ApiResponse<ContentOverviewPageResponse>) -> Unit
) {
compositeDisposable.add(
repository.getContents(authToken(), page, DEFAULT_PAGE_SIZE, type)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (generation == requestGeneration) {
onSuccess(it)
}
},
{
if (generation != requestGeneration) return@subscribe
it.message?.let { message -> Logger.e(message) }
_isLoading.value = false
val current = _overviewStateLiveData.value as? ContentOverviewUiState.Content
if (current != null && page > FIRST_PAGE) {
_overviewStateLiveData.value = current.copy(
isLoadingMore = false,
paginationErrorMessage = it.message
)
} else {
showFirstPageError(type, it.message)
}
}
)
)
}
private fun showFirstPageError(type: ContentOverviewType, message: String?) {
_overviewStateLiveData.value = ContentOverviewUiState.Error(type, message)
_toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown)
}
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
companion object {
const val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0
}
}

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.v2.main.content.overview.data
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query
interface ContentOverviewApi {
@GET("/api/v2/contents")
fun getContents(
@Header("Authorization") authHeader: String,
@Query("page") page: Int,
@Query("size") size: Int,
@Query("type") type: ContentOverviewType
): Single<ApiResponse<ContentOverviewPageResponse>>
}

View File

@@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.v2.main.content.overview.data
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class ContentOverviewPageResponse(
@SerializedName("type") val type: ContentOverviewType,
@SerializedName("items") val items: List<ContentOverviewItemResponse>,
@SerializedName("page") val page: Int,
@SerializedName("size") val size: Int,
@SerializedName("hasNext") val hasNext: Boolean
)
enum class ContentOverviewType {
NEW_AND_HOT_AUDIO
}
@Keep
data class ContentOverviewItemResponse(
@SerializedName("contentId") val contentId: Long,
@SerializedName("title") val title: String,
@SerializedName("coverImage") val coverImage: String?,
@SerializedName("price") val price: Int,
@SerializedName("isAdult") val isAdult: Boolean,
@SerializedName("isPointAvailable") val isPointAvailable: Boolean,
@SerializedName("isFirstContent") val isFirstContent: Boolean,
@SerializedName("isOriginalSeries") val isOriginalSeries: Boolean,
@SerializedName("creatorNickname") val creatorNickname: String
)

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.v2.main.content.overview.data
class ContentOverviewRepository(private val api: ContentOverviewApi) {
fun getContents(
token: String,
page: Int,
size: Int,
type: ContentOverviewType
) = api.getContents(
authHeader = token,
page = page,
size = size,
type = type
)
}

View File

@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.v2.main.content.overview.model
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewItemResponse
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewPageResponse
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
fun ContentOverviewPageResponse.toContent(): ContentOverviewUiState.Content {
return ContentOverviewUiState.Content(
type = type,
totalCount = items.size,
items = items.map { it.toUiModel() },
page = page,
size = size,
hasNext = hasNext
)
}
fun ContentOverviewItemResponse.toUiModel(): ContentOverviewUiModel = ContentOverviewUiModel(
contentId = contentId,
title = title,
coverImage = coverImage,
creatorNickname = creatorNickname,
tags = toAudioContentTags(),
showAdultBadge = isAdult
)
private fun ContentOverviewItemResponse.toAudioContentTags(): Set<AudioContentTag> = buildSet {
if (isOriginalSeries) add(AudioContentTag.Original)
if (isFirstContent) add(AudioContentTag.First)
if (isPointAvailable) add(AudioContentTag.Point)
if (price == 0) add(AudioContentTag.Free)
}

View File

@@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.v2.main.content.overview.model
import androidx.annotation.StringRes
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewType
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
data class ContentOverviewUiModel(
val contentId: Long,
val title: String,
val coverImage: String?,
val creatorNickname: String,
val tags: Set<AudioContentTag>,
val showAdultBadge: Boolean
)
@StringRes
fun ContentOverviewType.toTitleResId(): Int = when (this) {
ContentOverviewType.NEW_AND_HOT_AUDIO -> R.string.content_recommendation_section_new_and_hot
}

View File

@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.v2.main.content.overview.model
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewType
sealed interface ContentOverviewUiState {
data class Loading(val type: ContentOverviewType) : ContentOverviewUiState
data class Empty(val type: ContentOverviewType, val totalCount: Int) : ContentOverviewUiState
data class Error(val type: ContentOverviewType, val message: String?) : ContentOverviewUiState
data class Content(
val type: ContentOverviewType,
val totalCount: Int,
val items: List<ContentOverviewUiModel>,
val page: Int,
val size: Int,
val hasNext: Boolean,
val isLoadingMore: Boolean = false,
val paginationErrorMessage: String? = null
) : ContentOverviewUiState
}

View File

@@ -0,0 +1,57 @@
package kr.co.vividnext.sodalive.v2.main.content.overview.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.databinding.ItemContentAudioCardBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.content.overview.model.ContentOverviewUiModel
class ContentOverviewAdapter(
private val onContentClick: (Long) -> Unit = {}
) : RecyclerView.Adapter<ContentOverviewAdapter.ViewHolder>() {
private var items: List<ContentOverviewUiModel> = emptyList()
private var gridItemWidthPx: Int = 0
fun setGridItemWidthPx(widthPx: Int) {
if (widthPx <= 0 || gridItemWidthPx == widthPx) return
gridItemWidthPx = widthPx
notifyDataSetChanged()
}
fun submitItems(items: List<ContentOverviewUiModel>) {
this.items = items
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemContentAudioCardBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onContentClick
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position], gridItemWidthPx)
}
override fun getItemCount(): Int = items.size
class ViewHolder(
private val binding: ItemContentAudioCardBinding,
private val onContentClick: (Long) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: ContentOverviewUiModel, gridItemWidthPx: Int) = with(binding.audioContentCard.root) {
setGridItemWidthPx(gridItemWidthPx)
setContent(item.title, item.creatorNickname)
setTags(item.tags)
setAdultVisible(item.showAdultBadge)
thumbnailView().loadUrl(item.coverImage)
setOnClickListener {
if (item.contentId <= 0) return@setOnClickListener
onContentClick(item.contentId)
}
}
}
}

View File

@@ -5,7 +5,6 @@ import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
@@ -37,8 +36,6 @@ import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacter
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerUiModel
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCheerCreatorSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationFirstAudioContentSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationFirstAudioContentUiModel
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorGroupUiModel
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveSection
@@ -60,7 +57,6 @@ import kr.co.vividnext.sodalive.v2.main.home.ui.HomeAiCharacterAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBannerBinder
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBusinessInfoBinder
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeCheerCreatorAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFirstAudioAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowingChatAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowingCreatorAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowingLiveAdapter
@@ -86,7 +82,6 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private val liveAdapter = HomeLiveAdapter()
private val recentActivityCreatorAdapter = HomeRecentActivityCreatorAdapter { onRecentActivityClick(it) }
private val recentDebutCreatorAdapter = HomeRecentDebutCreatorAdapter { openCreatorProfile(it.creatorId) }
private val firstAudioAdapter = HomeFirstAudioAdapter { openAudioContentDetail(it) }
private val aiCharacterAdapter = HomeAiCharacterAdapter { onAiCharacterClick(it) }
private val genreCreatorAdapter = HomeGenreCreatorAdapter(
onFollowAllClick = { creatorIds -> onGenreFollowAllClick(creatorIds) },
@@ -166,7 +161,6 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
binding.rvHomeLives.adapter = liveAdapter
binding.rvHomeRecentActivityCreators.adapter = recentActivityCreatorAdapter
binding.rvHomeRecentDebutCreators.adapter = recentDebutCreatorAdapter
binding.rvHomeFirstAudioContents.adapter = firstAudioAdapter
binding.rvHomeAiCharacters.adapter = aiCharacterAdapter
binding.rvHomeGenreCreators.apply {
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
@@ -191,6 +185,9 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private fun setUpCreatorRankingAdapter() {
binding.rvHomeCreatorRankings.apply {
layoutManager = CreatorRankingAdapter.createGridLayoutManager(requireContext())
if (itemDecorationCount == 0) {
addItemDecoration(CreatorRankingAdapter.createItemDecoration(requireContext()))
}
adapter = creatorRankingAdapter
}
}
@@ -330,7 +327,6 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
bindBannerSection(content.banners)
bindRecentActivitySection(content.recentlyActiveCreators)
bindRecentDebutSection(content.recentDebutCreators)
bindFirstAudioSection(content.firstAudioContents)
bindAiCharacterSection(content.aiCharacters)
bindGenreCreatorSection(content.genreCreators)
bindCheerCreatorSection(content.cheerCreators)
@@ -403,11 +399,6 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
recentDebutCreatorAdapter.submitItems(section.items)
}
private fun bindFirstAudioSection(section: HomeRecommendationFirstAudioContentSection) {
binding.llHomeFirstAudioSection.visibility = section.items.toSectionVisibility()
firstAudioAdapter.submitItems(section.items)
}
private fun bindAiCharacterSection(section: HomeRecommendationAiCharacterSection) {
binding.llHomeAiCharacterSection.visibility = section.items.toSectionVisibility()
aiCharacterAdapter.submitItems(section.items)
@@ -453,10 +444,6 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
R.string.home_recommendation_section_recent_debut_creators,
showMore = true
)
binding.viewHomeFirstAudioTitle.setTitle(
R.string.home_recommendation_section_first_audio_contents,
showMore = true
)
binding.viewHomeAiCharacterTitle.setTitle(
R.string.home_recommendation_section_ai_characters,
showMore = true
@@ -560,16 +547,6 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
}
}
private fun openAudioContentDetail(item: HomeRecommendationFirstAudioContentUiModel) {
ensureMainV2NavigationAllowed {
startActivity(
Intent(requireContext(), AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, item.contentId)
}
)
}
}
private fun openPopularCommunityPost(item: FeedItem.Community) {
val creatorId = item.creatorId.toLongOrNull() ?: return
val postId = item.postId.toLongOrNull() ?: return
@@ -608,7 +585,6 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
banners = HomeRecommendationBannerSection(emptyList()),
recentlyActiveCreators = HomeRecommendationRecentlyActiveCreatorSection(emptyList()),
recentDebutCreators = HomeRecommendationRecentDebutCreatorSection(emptyList()),
firstAudioContents = HomeRecommendationFirstAudioContentSection(emptyList()),
aiCharacters = HomeRecommendationAiCharacterSection(emptyList()),
genreCreators = HomeRecommendationGenreCreatorSection(emptyList()),
cheerCreators = HomeRecommendationCheerCreatorSection(emptyList()),

View File

@@ -10,7 +10,6 @@ data class HomeRecommendationResponse(
@SerializedName("banners") val banners: List<HomeBannerItem>,
@SerializedName("recentlyActiveCreators") val recentlyActiveCreators: List<HomeActiveCreatorItem>,
@SerializedName("recentDebutCreators") val recentDebutCreators: List<HomeCreatorItem>,
@SerializedName("firstAudioContents") val firstAudioContents: List<HomeFirstAudioContentItem>,
@SerializedName("aiCharacters") val aiCharacters: List<HomeAiCharacterItem>,
@SerializedName("genreCreators") val genreCreators: List<HomeGenreCreatorGroupItem>,
@SerializedName("cheerCreators") val cheerCreators: List<HomeCreatorItem>,
@@ -49,18 +48,6 @@ data class HomeCreatorItem(
@SerializedName("creatorProfileImage") val creatorProfileImage: String
)
@Keep
data class HomeFirstAudioContentItem(
@SerializedName("contentId") val contentId: Long,
@SerializedName("creatorId") val creatorId: Long,
@SerializedName("creatorNickname") val creatorNickname: String,
@SerializedName("creatorProfileImage") val creatorProfileImage: String,
@SerializedName("title") val title: String,
@SerializedName("price") val price: Int,
@SerializedName("coverImage") val coverImage: String?,
@SerializedName("isPointAvailable") val isPointAvailable: Boolean
)
@Keep
data class HomeAiCharacterItem(
@SerializedName("characterId") val characterId: Long,

View File

@@ -6,12 +6,10 @@ import kr.co.vividnext.sodalive.v2.main.home.data.HomeActiveCreatorItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeAiCharacterItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeBannerItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeCreatorItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeFirstAudioContentItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeGenreCreatorGroupItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeLiveItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomePopularCommunityPostItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeRecommendationResponse
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
import kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailItem
import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem
@@ -23,7 +21,6 @@ fun HomeRecommendationResponse.toContent(): HomeRecommendationUiState.Content =
banners = HomeRecommendationBannerSection(banners.map { it.toUiModel() }),
recentlyActiveCreators = HomeRecommendationRecentlyActiveCreatorSection(recentlyActiveCreators.map { it.toUiModel() }),
recentDebutCreators = HomeRecommendationRecentDebutCreatorSection(recentDebutCreators.map { it.toUiModel() }),
firstAudioContents = HomeRecommendationFirstAudioContentSection(firstAudioContents.map { it.toUiModel() }),
aiCharacters = HomeRecommendationAiCharacterSection(aiCharacters.map { it.toUiModel() }),
genreCreators = HomeRecommendationGenreCreatorSection(genreCreators.map { it.toUiModel() }),
cheerCreators = HomeRecommendationCheerCreatorSection(cheerCreators.map { it.toUiModel() }),
@@ -62,22 +59,6 @@ fun HomeCreatorItem.toUiModel(): HomeRecommendationCreatorUiModel = HomeRecommen
profileImage = creatorProfileImage
)
fun HomeFirstAudioContentItem.toUiModel(): HomeRecommendationFirstAudioContentUiModel =
HomeRecommendationFirstAudioContentUiModel(
contentId = contentId,
creatorId = creatorId,
creatorNickname = creatorNickname,
creatorProfileImage = creatorProfileImage,
title = title,
price = price,
coverImage = coverImage,
tags = buildSet {
add(AudioContentTag.First)
if (isPointAvailable) add(AudioContentTag.Point)
if (price == 0) add(AudioContentTag.Free)
}
)
fun HomeAiCharacterItem.toUiModel(): HomeRecommendationAiCharacterUiModel = HomeRecommendationAiCharacterUiModel(
creatorId = creatorId,
item = CharacterChatThumbnailItem(

View File

@@ -12,7 +12,6 @@ import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
import kr.co.vividnext.sodalive.settings.event.EventItem
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
import kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailItem
import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem
import java.util.Locale
@@ -33,10 +32,6 @@ data class HomeRecommendationRecentDebutCreatorSection(
val items: List<HomeRecommendationCreatorUiModel>
)
data class HomeRecommendationFirstAudioContentSection(
val items: List<HomeRecommendationFirstAudioContentUiModel>
)
data class HomeRecommendationAiCharacterSection(
val items: List<HomeRecommendationAiCharacterUiModel>
)
@@ -179,17 +174,6 @@ data class HomeRecommendationCreatorUiModel(
val profileImage: String
)
data class HomeRecommendationFirstAudioContentUiModel(
val contentId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileImage: String,
val title: String,
val price: Int,
val coverImage: String?,
val tags: Set<AudioContentTag>
)
data class HomeRecommendationAiCharacterUiModel(
val creatorId: Long,
val item: CharacterChatThumbnailItem

View File

@@ -8,7 +8,6 @@ sealed interface HomeRecommendationUiState {
val banners: HomeRecommendationBannerSection,
val recentlyActiveCreators: HomeRecommendationRecentlyActiveCreatorSection,
val recentDebutCreators: HomeRecommendationRecentDebutCreatorSection,
val firstAudioContents: HomeRecommendationFirstAudioContentSection,
val aiCharacters: HomeRecommendationAiCharacterSection,
val genreCreators: HomeRecommendationGenreCreatorSection,
val cheerCreators: HomeRecommendationCheerCreatorSection,
@@ -19,7 +18,6 @@ sealed interface HomeRecommendationUiState {
banners.items,
recentlyActiveCreators.items,
recentDebutCreators.items,
firstAudioContents.items,
aiCharacters.items,
genreCreators.groups,
cheerCreators.items,

View File

@@ -1,105 +0,0 @@
package kr.co.vividnext.sodalive.v2.main.home.ui
import android.graphics.Outline
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationFirstAudioContentUiModel
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
class HomeFirstAudioAdapter(
private val onClickItem: (HomeRecommendationFirstAudioContentUiModel) -> Unit = {}
) : RecyclerView.Adapter<HomeFirstAudioAdapter.AudioViewHolder>() {
private var items: List<HomeRecommendationFirstAudioContentUiModel> = emptyList()
fun submitItems(items: List<HomeRecommendationFirstAudioContentUiModel>) {
this.items = items
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AudioViewHolder {
val view = LayoutInflater.from(parent.context).inflate(
R.layout.item_home_first_audio_content,
parent,
false
)
view.layoutParams = recyclerItemLayoutParams(parent, R.dimen.spacing_4)
return AudioViewHolder(view, onClickItem)
}
override fun onBindViewHolder(holder: AudioViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class AudioViewHolder(
private val view: View,
private val onClickItem: (HomeRecommendationFirstAudioContentUiModel) -> Unit
) : RecyclerView.ViewHolder(view) {
private val thumbnail: ImageView = view.findViewById(R.id.iv_home_first_audio_thumbnail)
private val thumbnailContainer: FrameLayout = view.findViewById(R.id.fl_home_first_audio_thumbnail_container)
private val topTagContainer: LinearLayout = view.findViewById(R.id.ll_home_first_audio_tag_top)
private val firstTag: LinearLayout = view.findViewById(R.id.ll_home_first_audio_tag_first)
private val bottomTagContainer: LinearLayout = view.findViewById(R.id.ll_home_first_audio_tag_bottom)
private val pointTag: ImageView = view.findViewById(R.id.iv_home_first_audio_tag_point)
private val freeTag: TextView = view.findViewById(R.id.tv_home_first_audio_tag_free)
private val creatorProfile: ImageView = view.findViewById(R.id.iv_home_first_audio_creator_profile)
private val creatorNickname: TextView = view.findViewById(R.id.tv_home_first_audio_creator_nickname)
init {
thumbnailContainer.clipToOutline = true
thumbnailContainer.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, THUMBNAIL_RADIUS_DP.dpToPx())
}
}
}
fun bind(item: HomeRecommendationFirstAudioContentUiModel) {
bindImage(thumbnail, item.coverImage)
bindImage(creatorProfile, item.creatorProfileImage)
creatorNickname.text = item.creatorNickname
bindTags(item.tags)
view.setOnClickListener { onClickItem(item) }
}
private fun bindImage(
imageView: ImageView,
imageUrl: String?
) {
if (imageUrl.isNullOrBlank()) {
imageView.setImageDrawable(null)
} else {
imageView.loadUrl(imageUrl)
}
}
private fun bindTags(tags: Set<AudioContentTag>) {
firstTag.visibility = if (AudioContentTag.First in tags) View.VISIBLE else View.GONE
pointTag.visibility = if (AudioContentTag.Point in tags) View.VISIBLE else View.GONE
freeTag.visibility = if (AudioContentTag.Free in tags) View.VISIBLE else View.GONE
topTagContainer.visibility = if (firstTag.visibility == View.VISIBLE) View.VISIBLE else View.GONE
bottomTagContainer.visibility = if (
pointTag.visibility == View.VISIBLE || freeTag.visibility == View.VISIBLE
) {
View.VISIBLE
} else {
View.GONE
}
}
private companion object {
const val THUMBNAIL_RADIUS_DP = 14f
}
}
}

View File

@@ -1,8 +1,10 @@
package kr.co.vividnext.sodalive.v2.main.home.ui
import android.graphics.Outline
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
@@ -22,7 +24,7 @@ class HomeRecentDebutCreatorAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CreatorViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_home_recent_debut_creator, parent, false)
view.layoutParams = recentDebutItemLayoutParams(parent)
view.layoutParams = view.layoutParams.recentDebutItemLayoutParams(parent)
return CreatorViewHolder(view, onClickItem)
}
@@ -39,6 +41,16 @@ class HomeRecentDebutCreatorAdapter(
private val profileImage = itemView.findViewById<ImageView>(R.id.iv_home_recent_debut_creator_profile)
private val nicknameText = itemView.findViewById<TextView>(R.id.tv_home_recent_debut_creator_nickname)
init {
itemView.clipToOutline = true
itemView.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
val radius = view.resources.getDimension(R.dimen.radius_14)
outline.setRoundRect(0, 0, view.width, view.height, radius)
}
}
}
fun bind(item: HomeRecommendationCreatorUiModel) {
profileImage.loadUrl(item.profileImage)
nicknameText.text = item.nickname
@@ -46,10 +58,9 @@ class HomeRecentDebutCreatorAdapter(
}
}
private fun recentDebutItemLayoutParams(parent: ViewGroup): RecyclerView.LayoutParams {
return RecyclerView.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply { marginEnd = parent.resources.getDimensionPixelSize(R.dimen.spacing_4) }
private fun ViewGroup.LayoutParams.recentDebutItemLayoutParams(parent: ViewGroup): RecyclerView.LayoutParams {
return RecyclerView.LayoutParams(this).apply {
marginEnd = parent.resources.getDimensionPixelSize(R.dimen.spacing_4)
}
}
}

View File

@@ -116,17 +116,12 @@ class ContentRankingHorizontalCardView @JvmOverloads constructor(
private fun positionViews(size: ContentRankingCardSize) {
val scale = size.widthPx / 374f
requireNotNull(rankGroup).layoutParams = LayoutParams(
(49 * scale).roundToInt(),
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = (14 * scale).roundToInt()
topMargin = (14 * scale).roundToInt()
}
requireNotNull(rankText).layoutParams = android.widget.LinearLayout.LayoutParams(
(48 * scale).roundToInt(),
(52 * scale).roundToInt()
)
requireNotNull(rankText).setPadding(0, 0, 0, (4 * scale).roundToInt())
requireNotNull(rankText).applyContentRankingRankGradient()
imageView().layoutParams = LayoutParams((80 * scale).roundToInt(), (80 * scale).roundToInt()).apply {
leftMargin = (77 * scale).roundToInt()

View File

@@ -1,7 +1,9 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import android.content.Context
import android.graphics.Rect
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.GridLayoutManager
@@ -16,26 +18,22 @@ class CreatorRankingAdapter(
private val items = mutableListOf<CreatorRankingItem>()
override fun getItemViewType(position: Int): Int = when (CreatorRankingPlacement.fromRank(items[position].rank).variant) {
CreatorRankingCardVariant.Large -> VIEW_TYPE_LARGE
CreatorRankingCardVariant.Compact -> VIEW_TYPE_COMPACT
CreatorRankingCardVariant.Horizontal -> VIEW_TYPE_HORIZONTAL
override fun getItemViewType(position: Int): Int {
val placement = CreatorRankingPlacement.fromRank(items[position].rank)
return when (placement.viewType) {
CreatorRankingViewType.TopTen -> VIEW_TYPE_TOP_TEN
CreatorRankingViewType.Lower -> VIEW_TYPE_LOWER
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_LARGE -> LargeViewHolder(
inflater.inflate(R.layout.view_creator_ranking_large_card, parent, false) as CreatorRankingLargeCardView,
parent
VIEW_TYPE_TOP_TEN -> TopTenViewHolder(
inflater.inflate(R.layout.view_creator_ranking_top_card, parent, false) as CreatorRankingTopCardView
)
VIEW_TYPE_COMPACT -> CompactViewHolder(
inflater.inflate(R.layout.view_creator_ranking_compact_card, parent, false) as CreatorRankingCompactCardView,
parent
)
VIEW_TYPE_HORIZONTAL -> HorizontalViewHolder(
inflater.inflate(R.layout.view_creator_ranking_horizontal_card, parent, false) as CreatorRankingHorizontalCardView,
parent
VIEW_TYPE_LOWER -> LowerViewHolder(
inflater.inflate(R.layout.view_creator_ranking_lower_row, parent, false) as CreatorRankingLowerRowView
)
else -> error("Unknown viewType: $viewType")
}
@@ -44,9 +42,8 @@ class CreatorRankingAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = items[position]
when (holder) {
is LargeViewHolder -> holder.bind(item)
is CompactViewHolder -> holder.bind(item)
is HorizontalViewHolder -> holder.bind(item)
is TopTenViewHolder -> holder.bind(item)
is LowerViewHolder -> holder.bind(item)
}
}
@@ -58,66 +55,37 @@ class CreatorRankingAdapter(
notifyDataSetChanged()
}
private inner class LargeViewHolder(
private val view: CreatorRankingLargeCardView,
private val parent: ViewGroup
private inner class TopTenViewHolder(
private val view: CreatorRankingTopCardView
) : RecyclerView.ViewHolder(view) {
fun bind(item: CreatorRankingItem) {
bindCommon(view, item, parent)
bindCommon(view, item)
view.bind(item)
view.setOnCreatorClick(onClickItem)
}
}
private inner class CompactViewHolder(
private val view: CreatorRankingCompactCardView,
private val parent: ViewGroup
private inner class LowerViewHolder(
private val view: CreatorRankingLowerRowView
) : RecyclerView.ViewHolder(view) {
fun bind(item: CreatorRankingItem) {
bindCommon(view, item, parent)
view.bind(item)
view.setOnCreatorClick(onClickItem)
}
}
private inner class HorizontalViewHolder(
private val view: CreatorRankingHorizontalCardView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: CreatorRankingItem) {
bindCommon(view, item, parent)
bindCommon(view, item)
view.bind(item)
view.setOnCreatorClick(onClickItem)
}
}
private fun bindCommon(
view: CreatorRankingLargeCardView,
item: CreatorRankingItem,
parent: ViewGroup
view: CreatorRankingTopCardView,
item: CreatorRankingItem
) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadCreatorImage(item)
}
private fun bindCommon(
view: CreatorRankingCompactCardView,
item: CreatorRankingItem,
parent: ViewGroup
view: CreatorRankingLowerRowView,
item: CreatorRankingItem
) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadCreatorImage(item)
}
private fun bindCommon(
view: CreatorRankingHorizontalCardView,
item: CreatorRankingItem,
parent: ViewGroup
) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadCreatorImage(item)
}
@@ -130,21 +98,6 @@ class CreatorRankingAdapter(
}
}
private fun calculateSize(
item: CreatorRankingItem,
parent: ViewGroup
): CreatorRankingCardSize {
val parentWidth = parent.width.takeIf { it > 0 } ?: parent.resources.displayMetrics.widthPixels
return CreatorRankingLayoutCalculator.calculate(
parentWidthPx = parentWidth,
parentHorizontalPaddingPx = parent.paddingLeft + parent.paddingRight,
horizontalGapPx = HORIZONTAL_GAP_DP.dpToPx(parent),
placement = CreatorRankingPlacement.fromRank(item.rank)
)
}
private fun Int.dpToPx(parent: ViewGroup): Int = (this * parent.resources.displayMetrics.density).roundToInt()
companion object {
const val GRID_SPAN_COUNT = 6
@@ -152,6 +105,10 @@ class CreatorRankingAdapter(
spanSizeLookup = createSpanSizeLookup()
}
fun createItemDecoration(context: Context): RecyclerView.ItemDecoration {
return CreatorRankingItemDecoration(HORIZONTAL_GAP_DP.dpToPx(context))
}
fun createSpanSizeLookup(): GridLayoutManager.SpanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int = when (CreatorRankingPlacement.fromRank(position + 1).itemsPerRow) {
1 -> GRID_SPAN_COUNT
@@ -161,9 +118,50 @@ class CreatorRankingAdapter(
}
}
private const val VIEW_TYPE_LARGE = 1
private const val VIEW_TYPE_COMPACT = 2
private const val VIEW_TYPE_HORIZONTAL = 3
private const val HORIZONTAL_GAP_DP = 4
private const val VIEW_TYPE_TOP_TEN = 1
private const val VIEW_TYPE_LOWER = 2
private const val HORIZONTAL_GAP_DP = 8
private fun Int.dpToPx(context: Context): Int = (this * context.resources.displayMetrics.density).roundToInt()
}
}
internal class CreatorRankingItemDecoration(
private val spacingPx: Int
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildAdapterPosition(view)
val itemCount = parent.adapter?.itemCount ?: return
if (position == RecyclerView.NO_POSITION) return
outRect.set(offsetsForPosition(position, itemCount))
}
fun offsetsForPosition(
adapterPosition: Int,
itemCount: Int
): Rect {
val placement = CreatorRankingPlacement.fromRank(adapterPosition + 1)
val indexInRow = adapterPosition.indexInRankingRow(placement.itemsPerRow)
return Rect().apply {
left = indexInRow * spacingPx / placement.itemsPerRow
right = spacingPx - (indexInRow + 1) * spacingPx / placement.itemsPerRow
if (adapterPosition < itemCount - 1) bottom = spacingPx
}
}
private fun Int.indexInRankingRow(itemsPerRow: Int): Int {
return when (this) {
0 -> 0
in 1..6 -> (this - 1) % itemsPerRow
in 7..9 -> (this - 7) % itemsPerRow
else -> 0
}
}
}

View File

@@ -1,7 +0,0 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
enum class CreatorRankingCardVariant {
Large,
Compact,
Horizontal
}

View File

@@ -1,190 +0,0 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import android.content.Context
import android.graphics.Outline
import android.graphics.RenderEffect
import android.graphics.Shader
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import kr.co.vividnext.sodalive.R
import kotlin.math.roundToInt
class CreatorRankingCompactCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var image: ImageView? = null
private var dimGradient: View? = null
private var deltaGroup: View? = null
private var rankText: TextView? = null
private var deltaAmountText: TextView? = null
private var deltaIcon: ImageView? = null
private var nameText: TextView? = null
private var currentItem: CreatorRankingItem? = null
private var clickListener: ((CreatorRankingItem) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
image = findViewById(R.id.iv_creator_ranking_image)
dimGradient = findViewById(R.id.v_creator_ranking_dim_gradient)
deltaGroup = findViewById(R.id.ll_creator_ranking_delta)
rankText = findViewById(R.id.tv_creator_ranking_rank)
deltaAmountText = findViewById(R.id.tv_creator_ranking_delta_amount)
deltaIcon = findViewById(R.id.iv_creator_ranking_delta_icon)
nameText = findViewById(R.id.tv_creator_ranking_name)
clipToOutline = true
outlineProvider = roundOutlineProvider()
imageView().outlineProvider = roundOutlineProvider()
imageView().clipToOutline = true
}
fun bind(item: CreatorRankingItem) {
currentItem = item
requireNotNull(rankText).apply {
text = item.rank.toString()
applyCreatorRankingRankGradient()
}
bindDelta(item)
requireNotNull(nameText).apply {
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
}
dimGradient?.visibility = View.VISIBLE
applyAccessState(item)
}
fun setCardSize(size: CreatorRankingCardSize) {
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
width = size.widthPx
height = size.heightPx
}
positionViews(size)
}
fun imageView(): ImageView = requireNotNull(image)
fun setOnCreatorClick(listener: ((CreatorRankingItem) -> Unit)?) {
clickListener = listener
currentItem?.let(::applyAccessState)
}
private fun bindDelta(item: CreatorRankingItem) {
requireNotNull(deltaGroup).visibility = if (item.showRankChange) View.VISIBLE else View.GONE
if (!item.showRankChange) return
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
applyDeltaContainer(presentation)
requireNotNull(deltaIcon).apply {
setImageResource(presentation.iconRes)
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
width = presentation.iconWidthDp.dpToPx()
height = presentation.iconHeightDp.dpToPx()
marginStart = presentation.iconMarginStartDp.dpToPx()
}
}
requireNotNull(deltaAmountText).apply {
text = presentation.amountText.orEmpty()
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
}
}
private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) {
requireNotNull(deltaGroup).apply {
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
}
}
private fun applyAccessState(item: CreatorRankingItem) {
applyBlur(item.isInaccessible)
isClickable = item.isTouchable && clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun positionViews(size: CreatorRankingCardSize) {
if (size.widthPx <= SMALL_THRESHOLD_PX) {
requireNotNull(rankText).textSize = 40f
requireNotNull(nameText).textSize = 14f
positionSmall(size)
} else {
requireNotNull(rankText).textSize = 54f
requireNotNull(nameText).textSize = 22f
positionMedium(size)
}
requireNotNull(rankText).applyCreatorRankingRankGradient()
}
private fun positionMedium(size: CreatorRankingCardSize) {
val scale = size.widthPx / 185f
requireNotNull(rankText).layoutParams = LayoutParams((56 * scale).roundToInt(), (70 * scale).roundToInt())
requireNotNull(rankText).setPadding(0, 0, 0, (6 * scale).roundToInt())
findViewById<View>(R.id.ll_creator_ranking_delta).layoutParams = LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = (10 * scale).roundToInt()
topMargin = (70 * scale).roundToInt()
}
requireNotNull(nameText).layoutParams = LayoutParams(
(165 * scale).roundToInt(),
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = ((size.widthPx - (165 * scale)) / 2f).roundToInt()
topMargin = (145 * scale).roundToInt()
}
}
private fun positionSmall(size: CreatorRankingCardSize) {
val scale = size.widthPx / 122f
requireNotNull(rankText).layoutParams = LayoutParams(
(52 * scale).roundToInt(),
(50 * scale).roundToInt()
).apply {
leftMargin = 0
}
requireNotNull(rankText).setPadding(0, 0, 0, (5 * scale).roundToInt())
findViewById<View>(R.id.ll_creator_ranking_delta).layoutParams = LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = (10 * scale).roundToInt()
topMargin = (50 * scale).roundToInt()
}
requireNotNull(nameText).layoutParams = LayoutParams(
(102 * scale).roundToInt(),
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = ((size.widthPx - (102 * scale)) / 2f).roundToInt()
topMargin = (98 * scale).roundToInt()
}
}
private fun applyBlur(enabled: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
imageView().setRenderEffect(
if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
)
}
}
private fun roundOutlineProvider() = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat())
}
}
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
private companion object {
const val SMALL_THRESHOLD_PX = 140
}
}

View File

@@ -1,35 +0,0 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
object CreatorRankingLayoutCalculator {
private const val HORIZONTAL_FIGMA_WIDTH = 374
private const val HORIZONTAL_FIGMA_HEIGHT = 100
fun calculate(
parentWidthPx: Int,
parentHorizontalPaddingPx: Int = 0,
horizontalGapPx: Int,
placement: CreatorRankingPlacement
): CreatorRankingCardSize {
require(parentWidthPx > 0) { "parentWidthPx must be > 0." }
require(parentHorizontalPaddingPx >= 0) { "parentHorizontalPaddingPx must be >= 0." }
require(horizontalGapPx >= 0) { "horizontalGapPx must be >= 0." }
require(placement.itemsPerRow > 0) { "itemsPerRow must be > 0." }
val totalGap = horizontalGapPx * (placement.itemsPerRow - 1)
val availableWidth = parentWidthPx - parentHorizontalPaddingPx
require(availableWidth > 0) { "available width must be > 0." }
val width = (availableWidth - totalGap) / placement.itemsPerRow
val height = when (placement.variant) {
CreatorRankingCardVariant.Large,
CreatorRankingCardVariant.Compact -> width
CreatorRankingCardVariant.Horizontal -> (width * HORIZONTAL_FIGMA_HEIGHT) / HORIZONTAL_FIGMA_WIDTH
}
return CreatorRankingCardSize(widthPx = width, heightPx = height)
}
}
data class CreatorRankingCardSize(
val widthPx: Int,
val heightPx: Int
)

View File

@@ -6,24 +6,27 @@ import android.graphics.RenderEffect
import android.graphics.Shader
import android.os.Build
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import kr.co.vividnext.sodalive.R
import kotlin.math.max
import kotlin.math.roundToInt
class CreatorRankingHorizontalCardView @JvmOverloads constructor(
class CreatorRankingLowerRowView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
) : ConstraintLayout(context, attrs, defStyleAttr) {
private var rankGroup: View? = null
private var image: ImageView? = null
private var rankGroup: View? = null
private var rankText: TextView? = null
private var deltaGroup: View? = null
private var deltaAmountText: TextView? = null
private var deltaIcon: ImageView? = null
private var nameText: TextView? = null
@@ -32,9 +35,10 @@ class CreatorRankingHorizontalCardView @JvmOverloads constructor(
override fun onFinishInflate() {
super.onFinishInflate()
rankGroup = findViewById(R.id.ll_creator_ranking_rank_group)
image = findViewById(R.id.iv_creator_ranking_image)
rankGroup = findViewById(R.id.ll_creator_ranking_rank_group)
rankText = findViewById(R.id.tv_creator_ranking_rank)
deltaGroup = findViewById(R.id.ll_creator_ranking_delta)
deltaAmountText = findViewById(R.id.tv_creator_ranking_delta_amount)
deltaIcon = findViewById(R.id.iv_creator_ranking_delta_icon)
nameText = findViewById(R.id.tv_creator_ranking_name)
@@ -42,23 +46,33 @@ class CreatorRankingHorizontalCardView @JvmOverloads constructor(
imageView().clipToOutline = true
}
fun bind(item: CreatorRankingItem) {
currentItem = item
requireNotNull(rankText).apply {
text = item.rank.toString()
applyCreatorRankingRankGradient()
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec)
val ratioHeight = (width * FIGMA_HEIGHT_RATIO_NUMERATOR) / FIGMA_HEIGHT_RATIO_DENOMINATOR
val ratioHeightSpec = MeasureSpec.makeMeasureSpec(ratioHeight, MeasureSpec.EXACTLY)
super.onMeasure(widthMeasureSpec, ratioHeightSpec)
val contentHeight = requireNotNull(rankGroup).measuredHeight + paddingTop + paddingBottom
val height = max(ratioHeight, contentHeight)
if (height != ratioHeight) {
super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY))
}
bindDelta(item)
requireNotNull(nameText).text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
applyAccessState(item)
}
fun setCardSize(size: CreatorRankingCardSize) {
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
width = size.widthPx
height = size.heightPx
fun bind(item: CreatorRankingItem) {
currentItem = item
val style = CreatorRankingTextStyle.fromRank(item.rank)
requireNotNull(rankText).apply {
text = item.rank.toString()
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.rankTextSizeSp.toFloat())
applyCreatorRankingRankGradient()
}
positionViews(size)
bindDelta(item, style)
requireNotNull(nameText).apply {
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.nameTextSizeSp.toFloat())
}
applyAccessState(item)
}
fun imageView(): ImageView = requireNotNull(image)
@@ -68,9 +82,11 @@ class CreatorRankingHorizontalCardView @JvmOverloads constructor(
currentItem?.let(::applyAccessState)
}
private fun bindDelta(item: CreatorRankingItem) {
val deltaGroup = findViewById<View>(R.id.ll_creator_ranking_delta)
deltaGroup.visibility = if (item.showRankChange) View.VISIBLE else View.GONE
private fun bindDelta(
item: CreatorRankingItem,
style: CreatorRankingTextStyle
) {
requireNotNull(deltaGroup).visibility = if (item.showRankChange) VISIBLE else GONE
if (!item.showRankChange) return
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
@@ -85,12 +101,13 @@ class CreatorRankingHorizontalCardView @JvmOverloads constructor(
}
requireNotNull(deltaAmountText).apply {
text = presentation.amountText.orEmpty()
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.deltaTextSizeSp.toFloat())
visibility = if (presentation.showAmount) VISIBLE else GONE
}
}
private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) {
findViewById<View>(R.id.ll_creator_ranking_delta).apply {
requireNotNull(deltaGroup).apply {
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
@@ -100,35 +117,7 @@ class CreatorRankingHorizontalCardView @JvmOverloads constructor(
private fun applyAccessState(item: CreatorRankingItem) {
applyBlur(item.isInaccessible)
isClickable = item.isTouchable && clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun positionViews(size: CreatorRankingCardSize) {
val scale = size.widthPx / 374f
requireNotNull(rankGroup).layoutParams = LayoutParams(
(49 * scale).roundToInt(),
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = (14 * scale).roundToInt()
topMargin = (12 * scale).roundToInt()
}
requireNotNull(rankText).layoutParams = android.widget.LinearLayout.LayoutParams(
(48 * scale).roundToInt(),
(52 * scale).roundToInt()
)
requireNotNull(rankText).setPadding(0, 0, 0, (4 * scale).roundToInt())
requireNotNull(rankText).applyCreatorRankingRankGradient()
imageView().layoutParams = LayoutParams((80 * scale).roundToInt(), (80 * scale).roundToInt()).apply {
leftMargin = (77 * scale).roundToInt()
topMargin = (10 * scale).roundToInt()
}
requireNotNull(nameText).layoutParams = LayoutParams(
(189 * scale).roundToInt(),
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = (171 * scale).roundToInt()
topMargin = (39 * scale).roundToInt()
}
setOnClickListener(if (isClickable) OnClickListener { clickListener?.invoke(item) } else null)
}
private fun applyBlur(enabled: Boolean) {
@@ -146,4 +135,9 @@ class CreatorRankingHorizontalCardView @JvmOverloads constructor(
}
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
private companion object {
const val FIGMA_HEIGHT_RATIO_NUMERATOR = 100
const val FIGMA_HEIGHT_RATIO_DENOMINATOR = 374
}
}

View File

@@ -1,17 +1,17 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
data class CreatorRankingPlacement(
val variant: CreatorRankingCardVariant,
val viewType: CreatorRankingViewType,
val itemsPerRow: Int
) {
companion object {
fun fromRank(rank: Int): CreatorRankingPlacement {
require(rank >= 1) { "rank must be greater than or equal to 1." }
return when (rank) {
1 -> CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
in 2..7 -> CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 2)
in 8..10 -> CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 3)
else -> CreatorRankingPlacement(CreatorRankingCardVariant.Horizontal, itemsPerRow = 1)
1 -> CreatorRankingPlacement(CreatorRankingViewType.TopTen, itemsPerRow = 1)
in 2..7 -> CreatorRankingPlacement(CreatorRankingViewType.TopTen, itemsPerRow = 2)
in 8..10 -> CreatorRankingPlacement(CreatorRankingViewType.TopTen, itemsPerRow = 3)
else -> CreatorRankingPlacement(CreatorRankingViewType.Lower, itemsPerRow = 1)
}
}
}

View File

@@ -0,0 +1,27 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
data class CreatorRankingTextStyle(
val rankTextSizeSp: Int,
val nameTextSizeSp: Int,
val deltaTextSizeSp: Int
) {
companion object {
fun fromPlacement(placement: CreatorRankingPlacement): CreatorRankingTextStyle {
return when (placement.viewType) {
CreatorRankingViewType.TopTen -> when (placement.itemsPerRow) {
1 -> CreatorRankingTextStyle(rankTextSizeSp = 96, nameTextSizeSp = 32, deltaTextSizeSp = 16)
2 -> CreatorRankingTextStyle(rankTextSizeSp = 54, nameTextSizeSp = 22, deltaTextSizeSp = 16)
3 -> CreatorRankingTextStyle(rankTextSizeSp = 36, nameTextSizeSp = 14, deltaTextSizeSp = 14)
else -> error("Unsupported top-ten row size: ${placement.itemsPerRow}")
}
CreatorRankingViewType.Lower -> CreatorRankingTextStyle(
rankTextSizeSp = 40,
nameTextSizeSp = 18,
deltaTextSizeSp = 16
)
}
}
fun fromRank(rank: Int): CreatorRankingTextStyle = fromPlacement(CreatorRankingPlacement.fromRank(rank))
}
}

View File

@@ -0,0 +1,28 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
data class CreatorRankingTopCardMargin(
val nameHorizontalDp: Int,
val nameBottomDp: Int,
val rankGroupStartDp: Int
) {
companion object {
fun fromRank(rank: Int): CreatorRankingTopCardMargin = when (rank) {
1 -> CreatorRankingTopCardMargin(
nameHorizontalDp = 20,
nameBottomDp = 24,
rankGroupStartDp = 10
)
in 2..7 -> CreatorRankingTopCardMargin(
nameHorizontalDp = 10,
nameBottomDp = 10,
rankGroupStartDp = 8
)
in 8..10 -> CreatorRankingTopCardMargin(
nameHorizontalDp = 10,
nameBottomDp = 10,
rankGroupStartDp = 6
)
else -> error("Unsupported top-card rank: $rank")
}
}
}

View File

@@ -6,25 +6,27 @@ import android.graphics.RenderEffect
import android.graphics.Shader
import android.os.Build
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import kr.co.vividnext.sodalive.R
import kotlin.math.roundToInt
class CreatorRankingLargeCardView @JvmOverloads constructor(
class CreatorRankingTopCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
) : ConstraintLayout(context, attrs, defStyleAttr) {
private var image: ImageView? = null
private var dimGradient: View? = null
private var deltaGroup: View? = null
private var rankText: TextView? = null
private var rankGroup: View? = null
private var deltaGroup: View? = null
private var deltaAmountText: TextView? = null
private var deltaIcon: ImageView? = null
private var nameText: TextView? = null
@@ -35,8 +37,9 @@ class CreatorRankingLargeCardView @JvmOverloads constructor(
super.onFinishInflate()
image = findViewById(R.id.iv_creator_ranking_image)
dimGradient = findViewById(R.id.v_creator_ranking_dim_gradient)
deltaGroup = findViewById(R.id.ll_creator_ranking_delta)
rankGroup = findViewById(R.id.ll_creator_ranking_rank_group)
rankText = findViewById(R.id.tv_creator_ranking_rank)
deltaGroup = findViewById(R.id.ll_creator_ranking_delta)
deltaAmountText = findViewById(R.id.tv_creator_ranking_delta_amount)
deltaIcon = findViewById(R.id.iv_creator_ranking_delta_icon)
nameText = findViewById(R.id.tv_creator_ranking_name)
@@ -46,27 +49,29 @@ class CreatorRankingLargeCardView @JvmOverloads constructor(
imageView().clipToOutline = true
}
fun bind(item: CreatorRankingItem) {
currentItem = item
requireNotNull(rankText).apply {
text = item.rank.toString()
applyCreatorRankingRankGradient()
}
bindDelta(item)
requireNotNull(nameText).apply {
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
}
dimGradient?.visibility = View.VISIBLE
applyAccessState(item)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec)
val squareHeightSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)
super.onMeasure(widthMeasureSpec, squareHeightSpec)
}
fun setCardSize(size: CreatorRankingCardSize) {
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
width = size.widthPx
height = size.heightPx
fun bind(item: CreatorRankingItem) {
currentItem = item
val style = CreatorRankingTextStyle.fromRank(item.rank)
applyMargins(CreatorRankingTopCardMargin.fromRank(item.rank))
requireNotNull(rankText).apply {
text = item.rank.toString()
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.rankTextSizeSp.toFloat())
applyCreatorRankingRankGradient()
}
positionViews(size)
bindDelta(item, style)
requireNotNull(nameText).apply {
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.nameTextSizeSp.toFloat())
visibility = if (text.isNullOrBlank()) INVISIBLE else VISIBLE
}
dimGradient?.visibility = VISIBLE
applyAccessState(item)
}
fun imageView(): ImageView = requireNotNull(image)
@@ -76,15 +81,31 @@ class CreatorRankingLargeCardView @JvmOverloads constructor(
currentItem?.let(::applyAccessState)
}
private fun bindDelta(item: CreatorRankingItem) {
requireNotNull(deltaGroup).visibility = if (item.showRankChange) View.VISIBLE else View.GONE
private fun applyMargins(margin: CreatorRankingTopCardMargin) {
requireNotNull(rankGroup).layoutParams =
(requireNotNull(rankGroup).layoutParams as MarginLayoutParams).apply {
marginStart = margin.rankGroupStartDp.dpToPx()
}
requireNotNull(nameText).layoutParams =
(requireNotNull(nameText).layoutParams as MarginLayoutParams).apply {
marginStart = margin.nameHorizontalDp.dpToPx()
marginEnd = margin.nameHorizontalDp.dpToPx()
bottomMargin = margin.nameBottomDp.dpToPx()
}
}
private fun bindDelta(
item: CreatorRankingItem,
style: CreatorRankingTextStyle
) {
requireNotNull(deltaGroup).visibility = if (item.showRankChange) VISIBLE else GONE
if (!item.showRankChange) return
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
applyDeltaContainer(presentation)
requireNotNull(deltaIcon).apply {
setImageResource(presentation.iconRes)
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
layoutParams = (layoutParams as MarginLayoutParams).apply {
width = presentation.iconWidthDp.dpToPx()
height = presentation.iconHeightDp.dpToPx()
marginStart = presentation.iconMarginStartDp.dpToPx()
@@ -92,7 +113,8 @@ class CreatorRankingLargeCardView @JvmOverloads constructor(
}
requireNotNull(deltaAmountText).apply {
text = presentation.amountText.orEmpty()
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.deltaTextSizeSp.toFloat())
visibility = if (presentation.showAmount) VISIBLE else GONE
}
}
@@ -107,31 +129,7 @@ class CreatorRankingLargeCardView @JvmOverloads constructor(
private fun applyAccessState(item: CreatorRankingItem) {
applyBlur(item.isInaccessible)
isClickable = item.isTouchable && clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun positionViews(size: CreatorRankingCardSize) {
val scale = size.widthPx / FIGMA_SIZE.toFloat()
requireNotNull(rankText).layoutParams = LayoutParams((86 * scale).roundToInt(), (116 * scale).roundToInt()).apply {
leftMargin = 0
topMargin = 0
}
requireNotNull(rankText).setPadding(0, 0, 0, (10 * scale).roundToInt())
requireNotNull(rankText).applyCreatorRankingRankGradient()
requireNotNull(nameText).layoutParams = LayoutParams(
(334 * scale).roundToInt(),
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = ((size.widthPx - (334 * scale)) / 2f).roundToInt()
topMargin = (305 * scale).roundToInt()
}
findViewById<View>(R.id.ll_creator_ranking_delta).layoutParams = LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = (20 * scale).roundToInt()
topMargin = (116 * scale).roundToInt()
}
setOnClickListener(if (isClickable) OnClickListener { clickListener?.invoke(item) } else null)
}
private fun applyBlur(enabled: Boolean) {
@@ -149,8 +147,4 @@ class CreatorRankingLargeCardView @JvmOverloads constructor(
}
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
private companion object {
const val FIGMA_SIZE = 374
}
}

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
enum class CreatorRankingViewType {
TopTen,
Lower
}

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layout_content_overview_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_content_overview_title_bar"
android:layout_width="0dp"
android:layout_height="60dp"
android:background="@color/black"
android:paddingHorizontal="@dimen/spacing_14"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/iv_content_overview_back"
android:layout_width="@dimen/spacing_24"
android:layout_height="@dimen/spacing_24"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:src="@drawable/ic_new_bar_back"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_content_overview_title"
style="@style/Typography.Heading2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_14"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/iv_content_overview_back"
app:layout_constraintTop_toTopOf="parent"
tools:text="New&amp;Hot" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_content_overview_items"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingTop="@dimen/spacing_8"
android:paddingBottom="@dimen/spacing_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_content_overview_title_bar"
tools:listitem="@layout/item_content_audio_card" />
<TextView
android:id="@+id/tv_content_overview_empty_error"
style="@style/Typography.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/gray_500"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_content_overview_title_bar"
tools:text="콘텐츠가 없습니다."
tools:visibility="visible" />
<ProgressBar
android:id="@+id/pb_content_overview_initial_loading"
android:layout_width="50dp"
android:layout_height="50dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_content_overview_title_bar"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/pb_content_overview_load_more"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginBottom="@dimen/spacing_16"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -115,28 +115,6 @@
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_home_first_audio_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_48"
android:orientation="vertical">
<include
android:id="@+id/view_home_first_audio_title"
layout="@layout/view_section_title" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_home_first_audio_contents"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_14"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_14"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_home_ai_character_section"
android:layout_width="match_parent"
@@ -382,5 +360,5 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_home"
tools:listitem="@layout/view_creator_ranking_horizontal_card" />
tools:listitem="@layout/view_creator_ranking_lower_row" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,120 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="185dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<FrameLayout
android:id="@+id/fl_home_first_audio_thumbnail_container"
android:layout_width="185dp"
android:layout_height="185dp"
android:background="@drawable/bg_audio_content_card_thumbnail"
android:outlineProvider="background">
<ImageView
android:id="@+id/iv_home_first_audio_thumbnail"
android:layout_width="185dp"
android:layout_height="185dp"
android:contentDescription="@null"
android:scaleType="centerCrop"
tools:src="@drawable/ic_launcher_background" />
<LinearLayout
android:id="@+id/ll_home_first_audio_tag_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:orientation="horizontal"
android:visibility="gone"
tools:visibility="visible">
<LinearLayout
android:id="@+id/ll_home_first_audio_tag_first"
android:layout_width="wrap_content"
android:layout_height="@dimen/spacing_24"
android:background="@drawable/bg_audio_content_tag_first"
android:gravity="center"
android:orientation="horizontal"
android:padding="4dp"
tools:ignore="UselessParent">
<ImageView
android:layout_width="17dp"
android:layout_height="17dp"
android:contentDescription="@null"
android:src="@drawable/ic_content_tag_first_star" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:fontFamily="@font/phosphate_solid"
android:singleLine="true"
android:text="FIRST"
android:textColor="@color/white"
android:textSize="16sp"
tools:ignore="HardcodedText" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/ll_home_first_audio_tag_bottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:orientation="horizontal"
android:visibility="gone"
tools:ignore="UseCompoundDrawables"
tools:visibility="visible">
<ImageView
android:id="@+id/iv_home_first_audio_tag_point"
android:layout_width="@dimen/spacing_24"
android:layout_height="@dimen/spacing_24"
android:contentDescription="@null"
android:src="@drawable/ic_content_tag_point" />
<TextView
android:id="@+id/tv_home_first_audio_tag_free"
style="@style/Typography.Body4"
android:layout_width="wrap_content"
android:layout_height="@dimen/spacing_24"
android:background="@drawable/bg_audio_content_tag_free"
android:gravity="center"
android:singleLine="true"
android:text="@string/audio_content_tag_free"
android:textColor="@color/white" />
</LinearLayout>
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_8"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="6dp">
<ImageView
android:id="@+id/iv_home_first_audio_creator_profile"
android:layout_width="42dp"
android:layout_height="42dp"
android:background="@drawable/bg_round_corner_999_263238"
android:contentDescription="@null"
android:scaleType="centerCrop"
tools:src="@drawable/ic_launcher_background" />
<TextView
android:id="@+id/tv_home_first_audio_creator_nickname"
style="@style/Typography.Body4"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
tools:text="크리에이터 이름" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,15 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="205dp"
android:layout_height="259dp"
android:background="@drawable/bg_home_recent_debut_card"
android:outlineProvider="background">
android:layout_width="185dp"
android:layout_height="234dp"
android:background="@drawable/bg_home_recent_debut_card">
<ImageView
android:id="@+id/iv_home_recent_debut_creator_profile"
android:layout_width="205dp"
android:layout_height="259dp"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="centerCrop"
tools:src="@drawable/ic_launcher_background" />

View File

@@ -6,7 +6,7 @@
<LinearLayout
android:id="@+id/ll_content_ranking_rank_group"
android:layout_width="49dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">

View File

@@ -1,74 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingCompactCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_creator_ranking_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_creator_ranking_image"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<View
android:id="@+id/v_creator_ranking_dim_gradient"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_creator_ranking_dim_gradient" />
<TextView
android:id="@+id/tv_creator_ranking_rank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/pattaya_regular"
android:gravity="center"
android:includeFontPadding="false"
android:shadowColor="#7A000000"
android:shadowRadius="4"
android:textColor="@color/white"
android:textSize="54sp"
tools:text="2" />
<LinearLayout
android:id="@+id/ll_creator_ranking_delta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_creator_ranking_delta"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="4dp">
<TextView
android:id="@+id/tv_creator_ranking_delta_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:textColor="@color/white"
android:textSize="16sp"
tools:text="4" />
<ImageView
android:id="@+id/iv_creator_ranking_delta_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginStart="2dp"
android:contentDescription="@null"
tools:src="@drawable/ic_rank_caret_increase" />
</LinearLayout>
<TextView
android:id="@+id/tv_creator_ranking_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/bold"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="22sp"
tools:text="크리에이터 이름" />
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingCompactCardView>

View File

@@ -1,74 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLargeCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_creator_ranking_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_creator_ranking_image"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<View
android:id="@+id/v_creator_ranking_dim_gradient"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_creator_ranking_dim_gradient" />
<TextView
android:id="@+id/tv_creator_ranking_rank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/pattaya_regular"
android:gravity="center"
android:includeFontPadding="false"
android:shadowColor="#7A000000"
android:shadowRadius="4"
android:textColor="@color/white"
android:textSize="96sp"
tools:text="1" />
<LinearLayout
android:id="@+id/ll_creator_ranking_delta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_creator_ranking_delta"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="4dp">
<TextView
android:id="@+id/tv_creator_ranking_delta_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:textColor="@color/white"
android:textSize="16sp"
tools:text="4" />
<ImageView
android:id="@+id/iv_creator_ranking_delta_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginStart="2dp"
android:contentDescription="@null"
tools:src="@drawable/ic_rank_caret_increase" />
</LinearLayout>
<TextView
android:id="@+id/tv_creator_ranking_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/bold"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="32sp"
tools:text="크리에이터 이름" />
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLargeCardView>

View File

@@ -1,27 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingHorizontalCardView xmlns:android="http://schemas.android.com/apk/res/android"
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLowerRowView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:paddingVertical="10dp">
<LinearLayout
android:id="@+id/ll_creator_ranking_rank_group"
android:layout_width="49dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
android:orientation="vertical"
android:paddingHorizontal="14dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tv_creator_ranking_rank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/pattaya_regular"
android:fontFamily="@font/phosphate_solid"
android:gravity="center"
android:includeFontPadding="false"
android:shadowColor="#7A000000"
android:shadowRadius="4"
android:textColor="@color/white"
android:textSize="40sp"
tools:text="11" />
<LinearLayout
@@ -40,7 +44,6 @@
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:textColor="@color/white"
android:textSize="16sp"
tools:text="4" />
<ImageView
@@ -55,21 +58,30 @@
<ImageView
android:id="@+id/iv_creator_ranking_image"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="14dp"
android:background="@drawable/bg_creator_ranking_image"
android:contentDescription="@null"
android:scaleType="centerCrop" />
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintStart_toEndOf="@id/ll_creator_ranking_rank_group"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_creator_ranking_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:ellipsize="end"
android:fontFamily="@font/bold"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/iv_creator_ranking_image"
app:layout_constraintTop_toTopOf="parent"
tools:text="크리에이터 이름" />
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingHorizontalCardView>
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLowerRowView>

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingTopCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/iv_creator_ranking_image"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/bg_creator_ranking_image"
android:contentDescription="@null"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/v_creator_ranking_dim_gradient"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/bg_creator_ranking_dim_gradient"
app:layout_constraintBottom_toBottomOf="@id/iv_creator_ranking_image"
app:layout_constraintEnd_toEndOf="@id/iv_creator_ranking_image"
app:layout_constraintStart_toStartOf="@id/iv_creator_ranking_image"
app:layout_constraintTop_toTopOf="@id/iv_creator_ranking_image" />
<LinearLayout
android:id="@+id/ll_creator_ranking_rank_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tv_creator_ranking_rank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/phosphate_solid"
android:gravity="center"
android:padding="0dp"
android:shadowColor="#7A000000"
android:shadowRadius="4"
android:textColor="@color/white"
tools:text="1" />
<LinearLayout
android:id="@+id/ll_creator_ranking_delta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_creator_ranking_delta"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="4dp">
<TextView
android:id="@+id/tv_creator_ranking_delta_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:textColor="@color/white"
tools:text="4" />
<ImageView
android:id="@+id/iv_creator_ranking_delta_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginStart="2dp"
android:contentDescription="@null"
tools:src="@drawable/ic_rank_caret_increase" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/tv_creator_ranking_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginBottom="20dp"
android:ellipsize="end"
android:fontFamily="@font/bold"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="크리에이터 이름" />
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingTopCardView>

View File

@@ -301,7 +301,6 @@
<string name="home_recommendation_section_live">Live</string>
<string name="home_recommendation_section_recently_active_creators">Recently active creators</string>
<string name="home_recommendation_section_recent_debut_creators">Recently debuted creators</string>
<string name="home_recommendation_section_first_audio_contents">Grow together from the start!</string>
<string name="home_recommendation_section_ai_characters">Chat with creators!</string>
<string name="home_recommendation_section_genre_creator_suffix">\u0020creators</string>
<string name="home_recommendation_section_cheer_creators">Creators with recent cheers</string>

View File

@@ -301,7 +301,6 @@
<string name="home_recommendation_section_live">ライブ</string>
<string name="home_recommendation_section_recently_active_creators">最近活動したクリエイター</string>
<string name="home_recommendation_section_recent_debut_creators">最近デビューしたクリエイター</string>
<string name="home_recommendation_section_first_audio_contents">最初から一緒に成長!</string>
<string name="home_recommendation_section_ai_characters">クリエイターと話しましょう!</string>
<string name="home_recommendation_section_genre_creator_suffix">のクリエイター</string>
<string name="home_recommendation_section_cheer_creators">最近応援が多いクリエイター</string>

View File

@@ -300,7 +300,6 @@
<string name="home_recommendation_section_live">라이브</string>
<string name="home_recommendation_section_recently_active_creators">방금 활동한 크리에이터</string>
<string name="home_recommendation_section_recent_debut_creators">최근 데뷔한 크리에이터</string>
<string name="home_recommendation_section_first_audio_contents">처음부터 함께 성장!</string>
<string name="home_recommendation_section_ai_characters">크리에이터와 이야기를 나눠요!</string>
<string name="home_recommendation_section_genre_creator_suffix">\u0020크리에이터</string>
<string name="home_recommendation_section_cheer_creators">최근 응원이 많은 크리에이터</string>

View File

@@ -1156,24 +1156,27 @@ class CreatorChannelActivitySourceTest {
}
@Test
fun `탭 전환은 sticky tabbar anchor 아래로 내려간 scroll 위치를 되돌리지 않고 부족할 때만 보정한다`() {
fun `탭 전환은 공통 scroll 위치를 자동 보정하지 않는다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
assertTrue(source.contains("private var lastSelectedCreatorChannelTabPosition: Int? = null"))
assertTrue(source.contains("lastSelectedCreatorChannelTabPosition = binding.viewPager.currentItem"))
assertTrue(source.contains("lastSelectedCreatorChannelTabPosition = position"))
assertTrue(source.contains("adjustCreatorChannelStickyAnchorOnTabSelected(position)"))
assertTrue(source.contains("private fun adjustCreatorChannelStickyAnchorOnTabSelected(position: Int)"))
assertTrue(source.contains("val previousPosition = lastSelectedCreatorChannelTabPosition"))
assertTrue(source.contains("if (previousPosition == null || previousPosition == position)"))
assertTrue(source.contains("val stickyScrollY = calculateCreatorChannelStickyScrollY()"))
assertTrue(source.contains("if (binding.nestedScrollView.scrollY < stickyScrollY)"))
assertTrue(source.contains("binding.nestedScrollView.scrollTo(0, stickyScrollY)"))
assertFalse(source.contains("private var lastSelectedCreatorChannelTabPosition: Int? = null"))
assertFalse(source.contains("lastSelectedCreatorChannelTabPosition = binding.viewPager.currentItem"))
assertFalse(source.contains("lastSelectedCreatorChannelTabPosition = position"))
assertFalse(source.contains("adjustCreatorChannelStickyAnchorOnTabSelected(position)"))
assertFalse(source.contains("private fun adjustCreatorChannelStickyAnchorOnTabSelected(position: Int)"))
assertFalse(source.contains("val previousPosition = lastSelectedCreatorChannelTabPosition"))
assertFalse(source.contains("binding.nestedScrollView.scrollTo(0, stickyScrollY)"))
assertFalse(source.contains("binding.nestedScrollView.smoothScrollTo(0, stickyScrollY)"))
assertTrue(source.contains("private fun calculateCreatorChannelStickyScrollY(): Int"))
assertTrue(source.contains("CreatorChannelScrollState.calculateStickyTop(statusBarHeight, baseTitleBarHeight)"))
assertTrue(source.contains("return (binding.headerContainer.height - stickyTop).coerceAtLeast(0)"))
assertTrue(source.contains("updateOwnerFabVisibility()"))
assertTrue(source.contains("updateDonationFloatingButtonVisibility()"))
assertTrue(source.contains("updateOwnerCtaVisibility()"))
assertTrue(source.contains("updateCreatorChannelTabViewportHeight()"))
assertTrue(source.contains("updateViewPagerHeight()"))
}
@Test

View File

@@ -46,6 +46,66 @@ class MainV2ActivitySourceTest {
assertTrue(dmRouteIndex < firstFallbackIndex)
}
@Test
fun `MainV2Activity 대화 탭 이동은 로그인 가드를 통과한 뒤 탭을 전환한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
assertTrue(source.contains("private fun selectChatTabWithLoginGuard(): Boolean"))
val guardSource = source.substringFrom("private fun selectChatTabWithLoginGuard(): Boolean")
assertBefore(guardSource, "if (!isLoggedIn())", "viewModel.clickTab(MainV2Tab.CHAT)")
assertBefore(guardSource, "showLoginActivity()", "return false")
assertTrue(guardSource.contains("return true"))
val setupViewSource = source.substringFrom("override fun setupView()")
assertTrue(setupViewSource.contains("selectChatTabWithLoginGuard()"))
assertFalse(setupViewSource.contains("viewModel.clickTab(MainV2Tab.CHAT)"))
val openChatTabSource = source.substringFrom("fun openChatTab()")
assertTrue(openChatTabSource.contains("selectChatTabWithLoginGuard()"))
assertFalse(openChatTabSource.contains("viewModel.clickTab(MainV2Tab.CHAT)"))
val openChatFilterSource = source.substringFrom("private fun openChatWithInitialFilter()")
assertTrue(openChatFilterSource.contains("selectChatTabWithLoginGuard()"))
assertFalse(openChatFilterSource.contains("viewModel.clickTab(MainV2Tab.CHAT)"))
val navigationSource = source.substringFrom("private fun setupBottomNavigation()")
assertTrue(navigationSource.contains("R.id.menu_main_v2_chat -> selectChatTabWithLoginGuard()"))
}
@Test
fun `MainV2Activity는 콘텐츠 전체 탭 type sort 진입 contract를 제공한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.common.data.ContentSort"))
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType"))
assertTrue(source.contains("fun openContentAllTab(type: MainContentAllType, sort: ContentSort = ContentSort.LATEST)"))
val openContentSource = source.substringFrom("fun openContentAllTab(type: MainContentAllType")
assertTrue(openContentSource.contains("if (viewModel.currentTab.value != MainV2Tab.CONTENT)"))
assertTrue(openContentSource.contains("viewModel.clickTab(MainV2Tab.CONTENT)"))
assertTrue(openContentSource.contains("changeFragment(MainV2Tab.CONTENT)"))
assertTrue(openContentSource.contains("supportFragmentManager.findFragmentByTag(MainV2Tab.CONTENT.toString())"))
assertTrue(openContentSource.contains("as? ContentMainFragment"))
assertTrue(openContentSource.contains("?.selectAllTab(type, sort)"))
assertBefore(openContentSource, "changeFragment(MainV2Tab.CONTENT)", "?.selectAllTab(type, sort)")
}
private fun assertBefore(source: String, expectedBefore: String, expectedAfter: String) {
val beforeIndex = source.indexOf(expectedBefore)
val afterIndex = source.indexOf(expectedAfter, beforeIndex)
assertTrue("Missing source: $expectedBefore", beforeIndex >= 0)
assertTrue("Missing source after $expectedBefore: $expectedAfter", afterIndex > beforeIndex)
}
private fun String.substringFrom(marker: String): String {
val startIndex = indexOf(marker)
assertTrue("Missing function: $marker", startIndex >= 0)
val nextFunctionIndex = indexOf("\n private fun ", startIndex + marker.length).takeIf { it >= 0 } ?: length
return substring(startIndex, nextFunctionIndex)
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }

View File

@@ -227,6 +227,70 @@ class ContentAllTabViewModelTest {
verifyGetContents(type = MainContentAllType.ORIGINAL, page = 0, dayOfWeek = null)
}
@Test
fun `외부 선택 요청은 FREE POPULAR 첫 페이지를 dayOfWeek 없이 요청한다`() {
stubGetContents(
type = MainContentAllType.FREE,
sort = ContentSort.POPULAR,
response = Single.just(
ApiResponse(
true,
response(
type = MainContentAllType.FREE,
sort = ContentSort.POPULAR,
audios = listOf(audio(30L))
),
null
)
)
)
viewModel.selectTypeAndSort(MainContentAllType.FREE, ContentSort.POPULAR)
val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
assertEquals(MainContentAllType.FREE, state.selectedType)
assertEquals(ContentSort.POPULAR, state.selectedSort)
assertEquals(null, state.selectedDayOfWeek)
assertEquals(0, state.page)
verifyGetContents(
type = MainContentAllType.FREE,
sort = ContentSort.POPULAR,
page = 0,
dayOfWeek = null
)
}
@Test
fun `외부 선택 요청은 ORIGINAL LATEST 첫 페이지를 dayOfWeek 없이 요청한다`() {
stubGetContents(
type = MainContentAllType.ORIGINAL,
response = Single.just(
ApiResponse(
true,
response(
type = MainContentAllType.ORIGINAL,
series = listOf(series(31L))
),
null
)
)
)
viewModel.selectTypeAndSort(MainContentAllType.ORIGINAL, ContentSort.LATEST)
val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
assertEquals(MainContentAllType.ORIGINAL, state.selectedType)
assertEquals(ContentSort.LATEST, state.selectedSort)
assertEquals(null, state.selectedDayOfWeek)
assertEquals(0, state.page)
verifyGetContents(
type = MainContentAllType.ORIGINAL,
sort = ContentSort.LATEST,
page = 0,
dayOfWeek = null
)
}
@Test
fun `SERIES에서 요일 변경은 변경 요일과 page 0으로 요청한다`() {
stubGetContents(response = Single.just(ApiResponse(true, response(), null)))

View File

@@ -283,6 +283,112 @@ class ContentMainFragmentSourceTest {
assertTrue(audioCardLayout.contains("android:visibility=\"gone\""))
}
@Test
fun `콘텐츠 추천 섹션 타이틀은 Phase 2 대상에만 chevron을 표시한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
).readText()
assertSourceContains(source, "private fun ViewSectionTitleBinding.setTitle(")
assertSourceContains(source, "showMore: Boolean = false")
assertSourceContains(source, "ivSectionTitleChevron.visibility = if (showMore) View.VISIBLE else View.GONE")
assertSourceContains(
source,
"binding.viewContentOriginalSeriesTitle.setTitle(\n" +
" R.string.content_recommendation_section_original_series,\n" +
" showMore = true"
)
assertSourceContains(
source,
"binding.viewContentLatestAudioTitle.setTitle(\n" +
" R.string.content_recommendation_section_latest_audio,\n" +
" showMore = true"
)
assertSourceContains(
source,
"binding.viewContentNewAndHotTitle.setTitle(\n" +
" R.string.content_recommendation_section_new_and_hot,\n" +
" showMore = true"
)
assertSourceContains(
source,
"binding.viewContentFreeAudioTitle.setTitle(\n" +
" R.string.content_recommendation_section_free_audio,\n" +
" showMore = true"
)
assertSourceContains(
source,
"binding.viewContentPointAudioTitle.setTitle(\n" +
" R.string.content_recommendation_section_point_audio,\n" +
" showMore = true"
)
assertSourceContains(
source,
"binding.viewContentMostCommentedAudioTitle.setTitle(" +
"R.string.content_recommendation_section_most_commented_audio)"
)
assertSourceContains(
source,
"binding.viewContentRecommendedAudioTitle.setTitle(" +
"R.string.content_recommendation_section_recommended_audio)"
)
assertTrue(source.contains("viewContentNewAndHotTitle.ivSectionTitleChevron.setOnClickListener"))
}
@Test
fun `content 전체 탭 외부 선택 source는 pending 처리와 중복 초기 로드 방지를 포함한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
).readText()
assertSourceContains(source, "fun selectAllTab(type: MainContentAllType, sort: ContentSort = ContentSort.LATEST)")
assertSourceContains(source, "private var pendingAllTabSelection: ContentAllTabSelection? = null")
assertSourceContains(source, "pendingAllTabSelection = ContentAllTabSelection(type, sort)")
assertSourceContains(source, "if (view != null)")
assertSourceContains(source, "applyPendingAllTabSelection()")
assertSourceContains(source, "private fun applyPendingAllTabSelection()")
assertSourceContains(source, "hasSelectedAllTab = true")
assertSourceContains(source, "binding.textTabBarContent.root.selectTab(CONTENT_TAB_ALL)")
assertSourceContains(source, "showContentTab(CONTENT_TAB_ALL)")
assertSourceContains(source, "contentAllTabViewModel.selectTypeAndSort(selection.type, selection.sort)")
assertTrue(
"전체 탭 selectTab listener가 loadInitial을 일으키지 않도록 hasSelectedAllTab을 먼저 설정해야 한다.",
source.indexOf("hasSelectedAllTab = true") <
source.indexOf("binding.textTabBarContent.root.selectTab(CONTENT_TAB_ALL)")
)
}
@Test
fun `content 추천 기존 전체보기 chevron은 MainV2Activity 전체 탭 routing을 호출한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
).readText()
assertSourceContains(source, "import kr.co.vividnext.sodalive.v2.main.MainV2Activity")
assertSourceContains(source, "private fun openContentAllTab(type: MainContentAllType, sort: ContentSort)")
assertSourceContains(source, "ensureMainV2NavigationAllowed")
assertSourceContains(source, "(activity as? MainV2Activity)?.openContentAllTab(type, sort)")
assertSourceContains(source, "binding.viewContentOriginalSeriesTitle.ivSectionTitleChevron.setOnClickListener")
assertSourceContains(source, "openContentAllTab(MainContentAllType.ORIGINAL, ContentSort.LATEST)")
assertSourceContains(source, "binding.viewContentLatestAudioTitle.ivSectionTitleChevron.setOnClickListener")
assertSourceContains(source, "openContentAllTab(MainContentAllType.AUDIO, ContentSort.LATEST)")
assertSourceContains(source, "binding.viewContentFreeAudioTitle.ivSectionTitleChevron.setOnClickListener")
assertSourceContains(source, "openContentAllTab(MainContentAllType.FREE, ContentSort.POPULAR)")
assertSourceContains(source, "binding.viewContentPointAudioTitle.ivSectionTitleChevron.setOnClickListener")
assertSourceContains(source, "openContentAllTab(MainContentAllType.POINT, ContentSort.POPULAR)")
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivity"))
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewType"))
assertTrue(source.contains("viewContentNewAndHotTitle.ivSectionTitleChevron.setOnClickListener"))
assertTrue(source.contains("openContentOverview(ContentOverviewType.NEW_AND_HOT_AUDIO)"))
val functionSource = source.substringFrom("private fun openContentOverview(type: ContentOverviewType)")
assertTrue(functionSource.contains("ensureMainV2NavigationAllowed"))
assertTrue(functionSource.contains("startActivity(ContentOverviewActivity.newIntent(requireContext(), type))"))
assertTrue(
functionSource.indexOf("ensureMainV2NavigationAllowed") <
functionSource.indexOf("startActivity(")
)
}
@Test
fun `Phase 4~5 adapter source는 grouping과 badge comment 정책을 포함한다`() {
val cardView = projectFile(
@@ -429,6 +535,15 @@ class ContentMainFragmentSourceTest {
assertTrue(message ?: "Expected source to contain: $expected", source.contains(expected))
}
private fun String.substringFrom(marker: String): String {
val startIndex = indexOf(marker)
assertTrue("Missing function: $marker", startIndex >= 0)
val nextFunctionIndex = indexOf("\n private fun ", startIndex + marker.length)
.takeIf { it >= 0 }
?: length
return substring(startIndex, nextFunctionIndex)
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }

View File

@@ -0,0 +1,105 @@
package kr.co.vividnext.sodalive.v2.main.content.overview
import android.app.Application
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class ContentOverviewActivitySourceTest {
@Test
fun `ContentOverviewActivity source has intent extra and two column grid contract`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivity.kt"
).readText()
assertTrue(source.contains("private const val EXTRA_CONTENT_OVERVIEW_TYPE"))
assertTrue(source.contains("fun newIntent(context: Context, type: ContentOverviewType): Intent"))
assertTrue(source.contains("putExtra(EXTRA_CONTENT_OVERVIEW_TYPE, type.name)"))
assertTrue(source.contains("CONTENT_OVERVIEW_GRID_SPAN_COUNT"))
assertTrue(source.contains("GridLayoutManager(this, CONTENT_OVERVIEW_GRID_SPAN_COUNT)"))
assertTrue(
source.contains(
"binding.rvContentOverviewItems.calculateContentGridItemWidthPx(CONTENT_OVERVIEW_GRID_SPAN_COUNT)"
)
)
assertTrue(source.contains("viewModel.loadMore()"))
}
@Test
fun `ContentOverview layout has Figma title bar and state views`() {
val layout = projectFile("app/src/main/res/layout/activity_content_overview.xml").readText()
assertTrue(layout.contains("@+id/layout_content_overview_root"))
assertTrue(layout.contains("android:background=\"@color/black\""))
assertTrue(layout.contains("@+id/layout_content_overview_title_bar"))
assertTrue(layout.contains("android:layout_height=\"60dp\""))
assertTrue(layout.contains("@+id/iv_content_overview_back"))
assertTrue(layout.contains("@drawable/ic_new_bar_back"))
assertTrue(layout.contains("@+id/tv_content_overview_title"))
assertTrue(layout.contains("@style/Typography.Heading2"))
assertTrue(layout.contains("@+id/rv_content_overview_items"))
assertTrue(layout.contains("@+id/pb_content_overview_initial_loading"))
assertTrue(layout.contains("@+id/pb_content_overview_load_more"))
assertTrue(layout.contains("@+id/tv_content_overview_empty_error"))
}
@Test
fun `ContentOverviewActivity renders first and load more loading states visibly`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivity.kt"
).readText()
assertTrue(source.contains("viewModel.isLoading.observe(this)"))
assertTrue(source.contains("pbContentOverviewInitialLoading.visibility = View.VISIBLE"))
assertTrue(source.contains("pbContentOverviewInitialLoading.visibility = View.GONE"))
assertTrue(source.contains("pbContentOverviewLoadMore.visibility = View.GONE"))
assertTrue(source.contains("pbContentOverviewLoadMore.visibility = if (state.isLoadingMore)"))
}
@Test
fun `ContentOverviewActivity opens audio detail with content id`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivity.kt"
).readText()
assertTrue(source.contains("AudioContentDetailActivity::class.java"))
assertTrue(source.contains("if (contentId <= 0) return"))
assertTrue(source.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)"))
}
@Test
fun `ContentOverviewAdapter ignores invalid content id clicks`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ui/ContentOverviewAdapter.kt"
).readText()
assertTrue(source.contains("if (item.contentId <= 0) return@setOnClickListener"))
assertTrue(source.contains("onContentClick(item.contentId)"))
}
@Test
fun `manifest registers ContentOverviewActivity and New Hot routing is added`() {
val manifest = projectFile("app/src/main/AndroidManifest.xml").readText()
val contentSource = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
).readText()
assertTrue(manifest.contains(".v2.main.content.overview.ContentOverviewActivity"))
assertTrue(contentSource.contains("viewContentNewAndHotTitle.ivSectionTitleChevron.setOnClickListener"))
}
private fun projectFile(relativePath: String): File {
val userDir = requireNotNull(System.getProperty("user.dir"))
val fromRoot = File(userDir, relativePath)
if (fromRoot.exists()) return fromRoot
val projectRoot = requireNotNull(File(userDir).parentFile)
return projectRoot.resolve(relativePath)
}
}

View File

@@ -0,0 +1,108 @@
package kr.co.vividnext.sodalive.v2.main.content.overview
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewItemResponse
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewPageResponse
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewType
import kr.co.vividnext.sodalive.v2.main.content.overview.model.toContent
import kr.co.vividnext.sodalive.v2.main.content.overview.model.toTitleResId
import kr.co.vividnext.sodalive.v2.main.content.overview.model.toUiModel
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ContentOverviewMapperTest {
@Test
fun `type title resource maps to overview screen title`() {
assertEquals(
R.string.content_recommendation_section_new_and_hot,
ContentOverviewType.NEW_AND_HOT_AUDIO.toTitleResId()
)
}
@Test
fun `audio item maps tags and adult badge`() {
val item = item(
price = 0,
isPointAvailable = true,
isFirstContent = true,
isOriginalSeries = true,
isAdult = true
).toUiModel()
assertEquals(
setOf(
AudioContentTag.Original,
AudioContentTag.First,
AudioContentTag.Point,
AudioContentTag.Free
),
item.tags
)
assertTrue(item.showAdultBadge)
}
@Test
fun `paid non adult item has no free tag and hides adult badge`() {
val item = item(price = 500, isAdult = false).toUiModel()
assertFalse(item.tags.contains(AudioContentTag.Free))
assertFalse(item.showAdultBadge)
}
@Test
fun `page response preserves paging metadata and type`() {
val content = pageResponse(
type = ContentOverviewType.NEW_AND_HOT_AUDIO,
page = 2,
size = 20,
hasNext = true,
items = listOf(item(contentId = 7L))
).toContent()
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, content.type)
assertEquals(2, content.page)
assertEquals(20, content.size)
assertTrue(content.hasNext)
assertEquals(listOf(7L), content.items.map { it.contentId })
}
private fun pageResponse(
type: ContentOverviewType = ContentOverviewType.NEW_AND_HOT_AUDIO,
items: List<ContentOverviewItemResponse> = listOf(item()),
page: Int = 0,
size: Int = 20,
hasNext: Boolean = false
) = ContentOverviewPageResponse(
type = type,
items = items,
page = page,
size = size,
hasNext = hasNext
)
private fun item(
contentId: Long = 1L,
title: String = "content",
coverImage: String? = "https://example.com/image.png",
price: Int = 100,
isAdult: Boolean = false,
isPointAvailable: Boolean = false,
isFirstContent: Boolean = false,
isOriginalSeries: Boolean = false,
creatorNickname: String = "creator"
) = ContentOverviewItemResponse(
contentId = contentId,
title = title,
coverImage = coverImage,
price = price,
isAdult = isAdult,
isPointAvailable = isPointAvailable,
isFirstContent = isFirstContent,
isOriginalSeries = isOriginalSeries,
creatorNickname = creatorNickname
)
}

View File

@@ -0,0 +1,259 @@
package kr.co.vividnext.sodalive.v2.main.content.overview
import android.app.Application
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.test.core.app.ApplicationProvider
import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins
import io.reactivex.rxjava3.core.Scheduler
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewItemResponse
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewPageResponse
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewRepository
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewType
import kr.co.vividnext.sodalive.v2.main.content.overview.model.ContentOverviewUiState
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class ContentOverviewViewModelTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var repository: ContentOverviewRepository
private lateinit var viewModel: ContentOverviewViewModel
@Before
fun setUp() {
setImmediateRxSchedulers()
SharedPreferenceManager.resetForTest()
SharedPreferenceManager.init(context)
SharedPreferenceManager.token = "test-token"
repository = org.mockito.kotlin.mock()
viewModel = ContentOverviewViewModel(repository)
}
@After
fun tearDown() {
RxJavaPlugins.reset()
RxAndroidPlugins.reset()
SharedPreferenceManager.resetForTest()
}
@Test
fun `초기 로드는 전달받은 type으로 첫 페이지를 요청하고 Content를 emit한다`() {
stubGetContents(
type = ContentOverviewType.NEW_AND_HOT_AUDIO,
response = Single.just(
ApiResponse(
true,
pageResponse(
type = ContentOverviewType.NEW_AND_HOT_AUDIO,
items = listOf(item(11L))
),
null
)
)
)
viewModel.loadFirstPage(ContentOverviewType.NEW_AND_HOT_AUDIO)
val state = viewModel.overviewStateLiveData.requireValue() as ContentOverviewUiState.Content
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, state.type)
assertEquals(0, state.page)
assertEquals(20, state.size)
assertEquals(listOf(11L), state.items.map { it.contentId })
verifyGetContents(type = ContentOverviewType.NEW_AND_HOT_AUDIO, page = 0)
}
@Test
fun `첫 페이지 성공이지만 items가 비어 있으면 Empty를 emit한다`() {
stubGetContents(
response = Single.just(
ApiResponse(
true,
pageResponse(items = emptyList()),
null
)
)
)
viewModel.loadFirstPage(ContentOverviewType.NEW_AND_HOT_AUDIO)
val state = viewModel.overviewStateLiveData.requireValue() as ContentOverviewUiState.Empty
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, state.type)
assertEquals(0, state.totalCount)
}
@Test
fun `첫 페이지 실패는 Error와 toast를 emit한다`() {
stubGetContents(
response = Single.just(ApiResponse(false, null, "failed"))
)
viewModel.loadFirstPage(ContentOverviewType.NEW_AND_HOT_AUDIO)
val state = viewModel.overviewStateLiveData.requireValue() as ContentOverviewUiState.Error
assertEquals("failed", state.message)
assertEquals(false, viewModel.isLoading.requireValue())
assertEquals(
kr.co.vividnext.sodalive.R.string.common_error_unknown,
viewModel.toastLiveData.requireValue()?.resId
)
}
@Test
fun `hasNext true이면 loadMore는 다음 page를 요청하고 append한다`() {
stubGetContents(
response = Single.just(
ApiResponse(
true,
pageResponse(items = listOf(item(1L)), hasNext = true),
null
)
)
)
stubGetContents(
page = 1,
response = Single.just(
ApiResponse(
true,
pageResponse(page = 1, items = listOf(item(2L)), hasNext = false),
null
)
)
)
viewModel.loadFirstPage(ContentOverviewType.NEW_AND_HOT_AUDIO)
viewModel.loadMore()
val state = viewModel.overviewStateLiveData.requireValue() as ContentOverviewUiState.Content
assertEquals(listOf(1L, 2L), state.items.map { it.contentId })
assertEquals(1, state.page)
assertFalse(state.hasNext)
verifyGetContents(page = 1)
}
@Test
fun `hasNext false이면 loadMore는 추가 요청하지 않는다`() {
stubGetContents(
response = Single.just(
ApiResponse(
true,
pageResponse(items = listOf(item(1L)), hasNext = false),
null
)
)
)
viewModel.loadFirstPage(ContentOverviewType.NEW_AND_HOT_AUDIO)
viewModel.loadMore()
verify(
repository,
never()
).getContents(
"Bearer test-token",
1,
ContentOverviewViewModel.DEFAULT_PAGE_SIZE,
ContentOverviewType.NEW_AND_HOT_AUDIO
)
}
@Test
fun `추가 페이지 실패 시 기존 items를 유지하고 pagination error를 emit한다`() {
stubGetContents(
response = Single.just(
ApiResponse(
true,
pageResponse(items = listOf(item(1L)), hasNext = true),
null
)
)
)
stubGetContents(
page = 1,
response = Single.just(ApiResponse(false, null, "page failed"))
)
viewModel.loadFirstPage(ContentOverviewType.NEW_AND_HOT_AUDIO)
viewModel.loadMore()
val state = viewModel.overviewStateLiveData.requireValue() as ContentOverviewUiState.Content
assertEquals(listOf(1L), state.items.map { it.contentId })
assertFalse(state.isLoadingMore)
assertEquals("page failed", state.paginationErrorMessage)
}
private fun stubGetContents(
type: ContentOverviewType = ContentOverviewType.NEW_AND_HOT_AUDIO,
page: Int = 0,
size: Int = ContentOverviewViewModel.DEFAULT_PAGE_SIZE,
response: Single<ApiResponse<ContentOverviewPageResponse>>
) {
whenever(repository.getContents("Bearer test-token", page, size, type)).thenReturn(response)
}
private fun verifyGetContents(
type: ContentOverviewType = ContentOverviewType.NEW_AND_HOT_AUDIO,
page: Int = 0,
size: Int = ContentOverviewViewModel.DEFAULT_PAGE_SIZE
) {
verify(repository).getContents("Bearer test-token", page, size, type)
}
private fun pageResponse(
type: ContentOverviewType = ContentOverviewType.NEW_AND_HOT_AUDIO,
items: List<ContentOverviewItemResponse> = listOf(item()),
page: Int = 0,
size: Int = 20,
hasNext: Boolean = false
) = ContentOverviewPageResponse(
type = type,
items = items,
page = page,
size = size,
hasNext = hasNext
)
private fun item(contentId: Long = 1L) = ContentOverviewItemResponse(
contentId = contentId,
title = "content $contentId",
coverImage = "https://example.com/$contentId.png",
price = 100,
isAdult = false,
isPointAvailable = false,
isFirstContent = false,
isOriginalSeries = false,
creatorNickname = "creator"
)
private fun setImmediateRxSchedulers() {
val trampoline = { _: Scheduler -> Schedulers.trampoline() }
RxJavaPlugins.setIoSchedulerHandler(trampoline)
RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
}
private fun <T> LiveData<T>.requireValue(): T? {
var value: T? = null
val observer = Observer<T> { value = it }
observeForever(observer)
removeObserver(observer)
return value
}
}

View File

@@ -35,14 +35,12 @@ import kr.co.vividnext.sodalive.v2.main.home.data.HomeActiveCreatorItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeAiCharacterItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeBannerItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeCreatorItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeFirstAudioContentItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeGenreCreatorGroupItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeLiveItem
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerRoute
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerUiModel
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterRoute
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationFirstAudioContentUiModel
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.HomeRecommendationGenreCreatorSection
@@ -66,7 +64,6 @@ import kr.co.vividnext.sodalive.v2.main.home.ui.HomeAiCharacterAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBannerBinder
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBusinessInfoBinder
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeCheerCreatorAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFirstAudioAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowAllButtonBinder
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeGenreCreatorAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeLiveAdapter
@@ -74,7 +71,6 @@ 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
import kr.co.vividnext.sodalive.v2.widget.banner.BannerView
import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem
@@ -122,7 +118,6 @@ class HomeMainFragmentLayoutTest {
R.id.ll_home_banner_section,
R.id.ll_home_recent_activity_section,
R.id.ll_home_recent_debut_section,
R.id.ll_home_first_audio_section,
R.id.ll_home_ai_character_section,
R.id.ll_home_genre_creator_section,
R.id.ll_home_cheer_creator_section,
@@ -132,7 +127,6 @@ class HomeMainFragmentLayoutTest {
R.id.rv_home_lives,
R.id.rv_home_recent_activity_creators,
R.id.rv_home_recent_debut_creators,
R.id.rv_home_first_audio_contents,
R.id.rv_home_ai_characters,
R.id.rv_home_genre_creators,
R.id.rv_home_cheer_creators
@@ -229,10 +223,11 @@ class HomeMainFragmentLayoutTest {
val profileImage = recentDebut.findViewById<ImageView>(R.id.iv_home_recent_debut_creator_profile)
val nicknameText = recentDebut.findViewById<TextView>(R.id.tv_home_recent_debut_creator_nickname)
assertEquals(205.dpToPx(), recentDebut.layoutParams.width)
assertEquals(259.dpToPx(), recentDebut.layoutParams.height)
assertEquals(205.dpToPx(), profileImage.layoutParams.width)
assertEquals(259.dpToPx(), profileImage.layoutParams.height)
assertEquals(185.dpToPx(), recentDebut.layoutParams.width)
assertEquals(234.dpToPx(), recentDebut.layoutParams.height)
assertEquals(R.drawable.bg_home_recent_debut_card, shadowOf(recentDebut.background).createdFromResId)
assertEquals(ViewGroup.LayoutParams.MATCH_PARENT, profileImage.layoutParams.width)
assertEquals(ViewGroup.LayoutParams.MATCH_PARENT, profileImage.layoutParams.height)
assertEquals(24f, nicknameText.textSize / nicknameText.resources.displayMetrics.scaledDensity)
assertEquals(Gravity.CENTER, nicknameText.gravity)
}
@@ -248,40 +243,11 @@ class HomeMainFragmentLayoutTest {
val layoutParams = viewHolder.itemView.layoutParams as ViewGroup.MarginLayoutParams
assertEquals(14.dpToPx(), recentDebutList.paddingStart)
assertEquals(185.dpToPx(), layoutParams.width)
assertEquals(234.dpToPx(), layoutParams.height)
assertEquals(4.dpToPx(), layoutParams.marginEnd)
}
@Test
fun `first audio item matches figma card dimensions`() {
val firstAudio = inflateViewWithParent(R.layout.item_home_first_audio_content)
val thumbnailContainer = firstAudio.findViewById<View>(R.id.fl_home_first_audio_thumbnail_container)
val thumbnail = firstAudio.findViewById<ImageView>(R.id.iv_home_first_audio_thumbnail)
val creatorProfile = firstAudio.findViewById<ImageView>(R.id.iv_home_first_audio_creator_profile)
val creatorName = firstAudio.findViewById<TextView>(R.id.tv_home_first_audio_creator_nickname)
assertEquals(185.dpToPx(), firstAudio.layoutParams.width)
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, firstAudio.layoutParams.height)
assertEquals(185.dpToPx(), thumbnail.layoutParams.width)
assertEquals(185.dpToPx(), thumbnail.layoutParams.height)
assertNotNull(thumbnailContainer)
assertEquals(42.dpToPx(), creatorProfile.layoutParams.width)
assertEquals(42.dpToPx(), creatorProfile.layoutParams.height)
assertEquals(14f, creatorName.textSize / creatorName.resources.displayMetrics.scaledDensity)
}
@Test
fun `home first audio section matches figma list spacing`() {
val root = inflateView(R.layout.fragment_v2_main_home)
val firstAudioList = root.findViewById<RecyclerView>(R.id.rv_home_first_audio_contents)
val context = ApplicationProvider.getApplicationContext<Context>()
val parent = RecyclerView(context)
parent.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
val viewHolder = HomeFirstAudioAdapter().onCreateViewHolder(parent, 0)
val layoutParams = viewHolder.itemView.layoutParams as ViewGroup.MarginLayoutParams
assertEquals(14.dpToPx(), firstAudioList.paddingStart)
assertEquals(14.dpToPx(), (firstAudioList.layoutParams as ViewGroup.MarginLayoutParams).topMargin)
assertEquals(4.dpToPx(), layoutParams.marginEnd)
assertTrue(viewHolder.itemView.clipToOutline)
assertNotNull(viewHolder.itemView.outlineProvider)
}
@Test
@@ -609,55 +575,6 @@ class HomeMainFragmentLayoutTest {
assertEquals(emptyList<HomeRecommendationGenreCreatorGroupUiModel>(), section.visibleHomeGenreCreatorGroups())
}
@Test
fun `first audio adapter clips thumbnail container`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val parent = RecyclerView(context)
parent.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
val viewHolder = HomeFirstAudioAdapter().onCreateViewHolder(parent, 0)
val thumbnailContainer = viewHolder.itemView.findViewById<View>(R.id.fl_home_first_audio_thumbnail_container)
assertEquals(true, thumbnailContainer.clipToOutline)
assertNotNull(thumbnailContainer.outlineProvider)
}
@Test
fun `first audio adapter binds tag visibility`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val parent = RecyclerView(context)
parent.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
val adapter = HomeFirstAudioAdapter()
adapter.submitItems(listOf(firstAudioItem()))
val viewHolder = adapter.onCreateViewHolder(parent, 0)
adapter.onBindViewHolder(viewHolder, 0)
assertEquals(View.VISIBLE, viewHolder.itemView.findViewById<View>(R.id.ll_home_first_audio_tag_top).visibility)
assertEquals(View.VISIBLE, viewHolder.itemView.findViewById<View>(R.id.ll_home_first_audio_tag_bottom).visibility)
assertEquals(View.VISIBLE, viewHolder.itemView.findViewById<View>(R.id.ll_home_first_audio_tag_first).visibility)
assertEquals(View.VISIBLE, viewHolder.itemView.findViewById<View>(R.id.iv_home_first_audio_tag_point).visibility)
assertEquals(View.VISIBLE, viewHolder.itemView.findViewById<View>(R.id.tv_home_first_audio_tag_free).visibility)
}
@Test
fun `first audio adapter clears nullable images`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val parent = RecyclerView(context)
parent.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
val adapter = HomeFirstAudioAdapter()
adapter.submitItems(listOf(firstAudioItem()))
val viewHolder = adapter.onCreateViewHolder(parent, 0)
val thumbnail = viewHolder.itemView.findViewById<ImageView>(R.id.iv_home_first_audio_thumbnail)
val creatorProfile = viewHolder.itemView.findViewById<ImageView>(R.id.iv_home_first_audio_creator_profile)
thumbnail.setImageResource(R.drawable.ic_launcher_background)
creatorProfile.setImageResource(R.drawable.ic_launcher_background)
adapter.onBindViewHolder(viewHolder, 0)
assertEquals(null, thumbnail.drawable)
assertEquals(null, creatorProfile.drawable)
}
@Test
fun `audio content card clips image area and overlay tags together`() {
val card = inflateView(R.layout.view_audio_content_card) as AudioContentCardView
@@ -668,58 +585,14 @@ class HomeMainFragmentLayoutTest {
}
@Test
fun `audio content card tag attributes match first audio item`() {
val card = inflateView(R.layout.view_audio_content_card) as AudioContentCardView
val firstAudio = inflateView(R.layout.item_home_first_audio_content)
card.setTags(setOf(AudioContentTag.Original, AudioContentTag.First, AudioContentTag.Point, AudioContentTag.Free))
val topTagContainer = card.findViewById<LinearLayout>(R.id.ll_audio_content_tag_top)
val bottomTagContainer = card.findViewById<LinearLayout>(R.id.ll_audio_content_tag_bottom)
val expectedFirstTag = firstAudio.findViewById<LinearLayout>(R.id.ll_home_first_audio_tag_first)
val expectedFreeTag = firstAudio.findViewById<TextView>(R.id.tv_home_first_audio_tag_free)
val originalTag = card.findViewById<ImageView>(R.id.iv_audio_content_tag_original)
val firstTag = card.findViewById<LinearLayout>(R.id.ll_audio_content_tag_first)
val firstIcon = firstTag.getChildAt(0) as ImageView
val firstText = firstTag.getChildAt(1) as TextView
val pointTag = card.findViewById<ImageView>(R.id.iv_audio_content_tag_point)
val freeTag = card.findViewById<TextView>(R.id.tv_audio_content_tag_free)
fun `home ranking layout declares ranking list without capsule tab source`() {
val layoutSource = homeMainLayoutSource()
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, topTagContainer.layoutParams.height)
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, bottomTagContainer.layoutParams.height)
assertEquals(24.dpToPx(), originalTag.layoutParams.width)
assertEquals(24.dpToPx(), originalTag.layoutParams.height)
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, firstTag.layoutParams.width)
assertEquals(expectedFirstTag.layoutParams.height, firstTag.layoutParams.height)
assertEquals(expectedFirstTag.paddingStart, firstTag.paddingStart)
assertEquals(expectedFirstTag.paddingTop, firstTag.paddingTop)
assertEquals(17.dpToPx(), firstIcon.layoutParams.width)
assertEquals(17.dpToPx(), firstIcon.layoutParams.height)
assertEquals(2.dpToPx(), (firstText.layoutParams as ViewGroup.MarginLayoutParams).marginStart)
assertEquals(true, firstText.includeFontPadding)
assertEquals(24.dpToPx(), pointTag.layoutParams.width)
assertEquals(24.dpToPx(), pointTag.layoutParams.height)
assertEquals(expectedFreeTag.layoutParams.height, freeTag.layoutParams.height)
assertEquals(expectedFreeTag.paddingStart, freeTag.paddingStart)
}
@Test
fun `home ranking layout contains ranking list below text tab bar`() {
val root = inflateView(R.layout.fragment_v2_main_home)
val rankingList = root.findViewById<RecyclerView>(R.id.rv_home_creator_rankings)
val layoutParams = rankingList.layoutParams as ConstraintLayout.LayoutParams
assertNotNull(rankingList)
assertSame(root, rankingList.parent)
assertEquals(0, layoutParams.width)
assertEquals(0, layoutParams.height)
assertEquals(R.id.text_tab_bar_home, layoutParams.topToBottom)
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, layoutParams.startToStart)
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, layoutParams.endToEnd)
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, layoutParams.bottomToBottom)
assertEquals(View.GONE, rankingList.visibility)
assertEquals(false, rankingList.clipToPadding)
assertEquals(14.dpToPx(), rankingList.paddingStart)
assertEquals(14.dpToPx(), rankingList.paddingTop)
assertEquals(28.dpToPx(), rankingList.paddingBottom)
assertTrue(layoutSource.contains("@+id/rv_home_creator_rankings"))
assertTrue(layoutSource.contains("@id/text_tab_bar_home"))
assertFalse(layoutSource.contains("view_capsule_tab_bar"))
assertFalse(layoutSource.contains("hsv_capsule_tab_bar"))
assertFalse(layoutSource.contains("ll_capsule_tab_container"))
}
@Test
@@ -856,16 +729,6 @@ class HomeMainFragmentLayoutTest {
creatorNickname = "추천 크리에이터",
creatorProfileImage = "https://example.com/creator.png"
).toUiModel()
val firstAudio = HomeFirstAudioContentItem(
contentId = 70L,
creatorId = 80L,
creatorNickname = "오디오 크리에이터",
creatorProfileImage = "https://example.com/audio-creator.png",
title = "첫 오디오",
price = 0,
coverImage = "https://example.com/cover.png",
isPointAvailable = true
).toUiModel()
val genreGroup = HomeGenreCreatorGroupItem(
genreName = "로맨스",
creators = listOf(
@@ -890,8 +753,6 @@ class HomeMainFragmentLayoutTest {
assertEquals(50L, activeCreator.targetId)
assertEquals("추천 크리에이터", creator.nickname)
assertEquals("https://example.com/creator.png", creator.profileImage)
assertEquals("https://example.com/audio-creator.png", firstAudio.creatorProfileImage)
assertEquals(setOf(AudioContentTag.First, AudioContentTag.Point, AudioContentTag.Free), firstAudio.tags)
assertEquals("로맨스", genreGroup.genre)
assertEquals("장르 크리에이터", genreGroup.creators.first().nickname)
}
@@ -1194,14 +1055,12 @@ class HomeMainFragmentLayoutTest {
val sectionTitleIds = listOf(
R.id.view_home_recent_activity_title,
R.id.view_home_recent_debut_title,
R.id.view_home_first_audio_title,
R.id.view_home_ai_character_title,
R.id.view_home_cheer_creator_title,
R.id.view_home_popular_community_title
)
val moreTitleIds = listOf(
R.id.view_home_recent_debut_title,
R.id.view_home_first_audio_title,
R.id.view_home_ai_character_title
)
@@ -1330,6 +1189,7 @@ class HomeMainFragmentLayoutTest {
assertTrue(source.contains("CreatorRankingAdapter { openCreatorRankingProfile(it) }"))
assertTrue(source.contains("binding.rvHomeCreatorRankings.apply"))
assertTrue(source.contains("layoutManager = CreatorRankingAdapter.createGridLayoutManager(requireContext())"))
assertTrue(source.contains("addItemDecoration(CreatorRankingAdapter.createItemDecoration(requireContext()))"))
assertTrue(source.contains("adapter = creatorRankingAdapter"))
}
@@ -1492,19 +1352,6 @@ class HomeMainFragmentLayoutTest {
)
}
private fun firstAudioItem(): HomeRecommendationFirstAudioContentUiModel {
return HomeRecommendationFirstAudioContentUiModel(
contentId = 1L,
creatorId = 1L,
creatorNickname = "크리에이터 이름",
creatorProfileImage = "",
title = "콘텐츠 제목",
price = 0,
coverImage = null,
tags = setOf(AudioContentTag.First, AudioContentTag.Point, AudioContentTag.Free)
)
}
private fun homeBanner(
creatorId: Long? = null,
seriesId: Long? = null,

View File

@@ -23,7 +23,6 @@ class HomeMainFragmentLoginGuardSourceTest {
)
assertGuardedStartActivity(source, "private fun onAiCharacterClick(item: HomeRecommendationAiCharacterUiModel)")
assertGuardedStartActivity(source, "private fun openCreatorProfile(creatorId: Long)")
assertGuardedStartActivity(source, "private fun openAudioContentDetail(item: HomeRecommendationFirstAudioContentUiModel)")
assertGuardedStartActivity(source, "private fun openPopularCommunityPost(item: FeedItem.Community)")
}

View File

@@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.v2.main.home
import android.app.Application
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class HomeMainFragmentSourceTest {
@Test
fun `홈 추천 탭에서 처음부터 함께 성장 섹션 코드는 제거되어 있다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
).readText()
assertFalse(source.contains("HomeFirstAudioAdapter"))
assertFalse(source.contains("HomeRecommendationFirstAudioContent"))
assertFalse(source.contains("firstAudio"))
assertFalse(source.contains("FirstAudio"))
assertFalse(source.contains("rvHomeFirstAudioContents"))
assertFalse(source.contains("viewHomeFirstAudioTitle"))
assertFalse(source.contains("ContentOverviewType.FIRST_AUDIO_CONTENT"))
}
private fun String.substringFrom(marker: String): String {
val startIndex = indexOf(marker)
assertTrue("Missing function: $marker", startIndex >= 0)
val nextFunctionIndex = indexOf("\n private fun ", startIndex + marker.length)
.takeIf { it >= 0 }
?: length
return substring(startIndex, nextFunctionIndex)
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
}

View File

@@ -75,22 +75,18 @@ class ContentRankingCardViewTest {
val view = inflateView<ContentRankingHorizontalCardView>(R.layout.view_content_ranking_horizontal_card)
view.setCardSize(ContentRankingCardSize(widthPx = 374, heightPx = 100))
view.bind(sampleItem(rank = 20))
val rankGroup = view.findViewById<View>(R.id.ll_content_ranking_rank_group)
val rankGroupParams = rankGroup.layoutParams as ViewGroup.MarginLayoutParams
assertEquals(49, rankGroupParams.width)
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, rankGroupParams.width)
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, rankGroupParams.height)
assertEquals(14, rankGroupParams.leftMargin)
assertEquals(14, rankGroupParams.topMargin)
assertRankTextBox(
view = view.findViewById(R.id.tv_content_ranking_rank),
expectedWidth = 48,
expectedHeight = 52,
expectedLeft = 0,
expectedTop = 0,
expectedBottomPadding = 4
)
val rankText = view.findViewById<TextView>(R.id.tv_content_ranking_rank)
assertEquals("20", rankText.text.toString())
assertRankTextWrapContent(rankText)
}
@Test
@@ -188,6 +184,14 @@ class ContentRankingCardViewTest {
assertEquals(expectedBottomPadding, view.paddingBottom)
}
private fun assertRankTextWrapContent(view: TextView) {
val params = view.layoutParams as ViewGroup.MarginLayoutParams
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, params.width)
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, params.height)
assertEquals(Gravity.CENTER, view.gravity)
assertEquals(false, view.includeFontPadding)
}
private fun sampleItem(
rank: Int = 1,
showRankChange: Boolean = true

View File

@@ -1,32 +1,17 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import android.app.Application
import android.content.Context
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType.Increase
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class CreatorRankingAdapterLayoutTest {
@Test
fun `span count supports full two and three column ranking rows`() {
fun `grid span count는 1 2 3 item row를 모두 표현한다`() {
assertEquals(6, CreatorRankingAdapter.GRID_SPAN_COUNT)
}
@Test
fun `span lookup maps rank groups to expected row widths`() {
fun `span lookup rank 구간별 items per row를 span 크기로 변환한다`() {
val spanLookup = CreatorRankingAdapter.createSpanSizeLookup()
assertEquals(6, spanLookup.getSpanSize(0))
@@ -42,200 +27,24 @@ class CreatorRankingAdapterLayoutTest {
}
@Test
fun `large card 순위 숫자는 Figma 고정 박스 안에서 중앙 정렬된다`() {
val view = inflateView<CreatorRankingLargeCardView>(R.layout.view_creator_ranking_large_card)
fun `item decoration은 rank 구간별 row index로 gap을 분배한다`() {
val spacing = 8
val decoration = CreatorRankingItemDecoration(spacing)
view.setCardSize(CreatorRankingCardSize(widthPx = 374, heightPx = 374))
val first = decoration.offsetsForPosition(adapterPosition = 0, itemCount = 12)
val second = decoration.offsetsForPosition(adapterPosition = 1, itemCount = 12)
val third = decoration.offsetsForPosition(adapterPosition = 2, itemCount = 12)
val eighth = decoration.offsetsForPosition(adapterPosition = 7, itemCount = 12)
val ninth = decoration.offsetsForPosition(adapterPosition = 8, itemCount = 12)
val tenth = decoration.offsetsForPosition(adapterPosition = 9, itemCount = 12)
val eleventh = decoration.offsetsForPosition(adapterPosition = 10, itemCount = 12)
assertRankTextBox(
view = view.findViewById(R.id.tv_creator_ranking_rank),
expectedWidth = 86,
expectedHeight = 116,
expectedLeft = 0,
expectedTop = 0,
expectedBottomPadding = 10
)
assertEquals(spacing, first.bottom)
assertEquals(spacing, second.right + third.left)
assertEquals(spacing, second.bottom)
assertEquals(spacing, eighth.right + ninth.left)
assertEquals(spacing, ninth.right + tenth.left)
assertEquals(spacing, tenth.bottom)
assertEquals(spacing, eleventh.bottom)
}
@Test
fun `compact medium card 순위 숫자는 Figma 고정 박스 안에서 중앙 정렬된다`() {
val view = inflateView<CreatorRankingCompactCardView>(R.layout.view_creator_ranking_compact_card)
view.setCardSize(CreatorRankingCardSize(widthPx = 185, heightPx = 185))
assertRankTextBox(
view = view.findViewById(R.id.tv_creator_ranking_rank),
expectedWidth = 56,
expectedHeight = 70,
expectedLeft = 0,
expectedTop = 0,
expectedBottomPadding = 6
)
}
@Test
fun `compact small card 순위 숫자는 Figma 고정 박스 안에서 중앙 정렬된다`() {
val view = inflateView<CreatorRankingCompactCardView>(R.layout.view_creator_ranking_compact_card)
view.setCardSize(CreatorRankingCardSize(widthPx = 122, heightPx = 122))
assertRankTextBox(
view = view.findViewById(R.id.tv_creator_ranking_rank),
expectedWidth = 52,
expectedHeight = 50,
expectedLeft = 0,
expectedTop = 0,
expectedBottomPadding = 5
)
}
@Test
fun `horizontal card 순위 그룹은 Figma 고정 박스 위치를 유지한다`() {
val view = inflateView<CreatorRankingHorizontalCardView>(R.layout.view_creator_ranking_horizontal_card)
view.setCardSize(CreatorRankingCardSize(widthPx = 374, heightPx = 100))
val rankGroup = view.findViewById<View>(R.id.ll_creator_ranking_rank_group)
val params = rankGroup.layoutParams as ViewGroup.MarginLayoutParams
assertEquals(49, params.width)
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, params.height)
assertEquals(14, params.leftMargin)
assertEquals(12, params.topMargin)
assertRankTextBox(
view = view.findViewById(R.id.tv_creator_ranking_rank),
expectedWidth = 48,
expectedHeight = 52,
expectedLeft = 0,
expectedTop = 0,
expectedBottomPadding = 4
)
}
@Test
fun `large card 순위 변화 표시는 Figma 위치를 유지한다`() {
val view = inflateView<CreatorRankingLargeCardView>(R.layout.view_creator_ranking_large_card)
view.setCardSize(CreatorRankingCardSize(widthPx = 374, heightPx = 374))
assertViewPosition(
view = view.findViewById(R.id.ll_creator_ranking_delta),
expectedLeft = 20,
expectedTop = 116
)
}
@Test
fun `compact small card 순위 변화 표시는 Figma 위치를 유지한다`() {
val view = inflateView<CreatorRankingCompactCardView>(R.layout.view_creator_ranking_compact_card)
view.setCardSize(CreatorRankingCardSize(widthPx = 122, heightPx = 122))
assertViewPosition(
view = view.findViewById(R.id.ll_creator_ranking_delta),
expectedLeft = 10,
expectedTop = 50
)
}
@Test
fun `large card는 순위 변동 숨김이면 delta를 숨긴다`() {
val view = inflateView<CreatorRankingLargeCardView>(R.layout.view_creator_ranking_large_card)
view.bind(sampleItem(showRankChange = false))
assertEquals(View.GONE, view.findViewById<View>(R.id.ll_creator_ranking_delta).visibility)
}
@Test
fun `large card는 순위 변동 표시이면 delta를 보여준다`() {
val view = inflateView<CreatorRankingLargeCardView>(R.layout.view_creator_ranking_large_card)
view.bind(sampleItem(showRankChange = true))
assertEquals(View.VISIBLE, view.findViewById<View>(R.id.ll_creator_ranking_delta).visibility)
}
@Test
fun `compact card는 순위 변동 숨김이면 delta를 숨긴다`() {
val view = inflateView<CreatorRankingCompactCardView>(R.layout.view_creator_ranking_compact_card)
view.bind(sampleItem(showRankChange = false))
assertEquals(View.GONE, view.findViewById<View>(R.id.ll_creator_ranking_delta).visibility)
}
@Test
fun `compact card는 순위 변동 표시이면 delta를 보여준다`() {
val view = inflateView<CreatorRankingCompactCardView>(R.layout.view_creator_ranking_compact_card)
view.bind(sampleItem(showRankChange = true))
assertEquals(View.VISIBLE, view.findViewById<View>(R.id.ll_creator_ranking_delta).visibility)
}
@Test
fun `horizontal card는 순위 변동 숨김이면 delta를 숨긴다`() {
val view = inflateView<CreatorRankingHorizontalCardView>(R.layout.view_creator_ranking_horizontal_card)
view.bind(sampleItem(rank = 11, showRankChange = false))
assertEquals(View.GONE, view.findViewById<View>(R.id.ll_creator_ranking_delta).visibility)
}
@Test
fun `horizontal card는 순위 변동 표시이면 delta를 보여준다`() {
val view = inflateView<CreatorRankingHorizontalCardView>(R.layout.view_creator_ranking_horizontal_card)
view.bind(sampleItem(rank = 11, showRankChange = true))
assertEquals(View.VISIBLE, view.findViewById<View>(R.id.ll_creator_ranking_delta).visibility)
}
private fun assertViewPosition(
view: View,
expectedLeft: Int,
expectedTop: Int
) {
val params = view.layoutParams as ViewGroup.MarginLayoutParams
assertEquals(expectedLeft, params.leftMargin)
assertEquals(expectedTop, params.topMargin)
}
private fun assertRankTextBox(
view: TextView,
expectedWidth: Int,
expectedHeight: Int,
expectedLeft: Int,
expectedTop: Int,
expectedBottomPadding: Int
) {
val params = view.layoutParams as ViewGroup.MarginLayoutParams
assertEquals(expectedWidth, params.width)
assertEquals(expectedHeight, params.height)
assertEquals(expectedLeft, params.leftMargin)
assertEquals(expectedTop, params.topMargin)
assertEquals(Gravity.CENTER, view.gravity)
assertEquals(false, view.includeFontPadding)
assertEquals(expectedBottomPadding, view.paddingBottom)
}
private inline fun <reified T : View> inflateView(layoutResId: Int): T {
val context = ApplicationProvider.getApplicationContext<Context>()
return LayoutInflater.from(context).inflate(layoutResId, null, false) as T
}
private fun sampleItem(
rank: Int = 1,
showRankChange: Boolean = true
) = CreatorRankingItem(
creatorId = 1L,
rank = rank,
rankChangeType = Increase,
rankChangeAmount = 4,
creatorName = "크리에이터 이름",
imageUrl = "https://example.com/image.png",
isBlocked = false,
showRankChange = showRankChange
)
}

View File

@@ -1,68 +0,0 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import org.junit.Assert.assertEquals
import org.junit.Test
class CreatorRankingLayoutCalculatorTest {
@Test
fun `large card fills available width as square`() {
val size = CreatorRankingLayoutCalculator.calculate(
parentWidthPx = 374,
horizontalGapPx = 4,
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
)
assertEquals(374, size.widthPx)
assertEquals(374, size.heightPx)
}
@Test
fun `large card excludes parent horizontal padding from available width`() {
val size = CreatorRankingLayoutCalculator.calculate(
parentWidthPx = 374,
parentHorizontalPaddingPx = 40,
horizontalGapPx = 4,
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
)
assertEquals(334, size.widthPx)
assertEquals(334, size.heightPx)
}
@Test
fun `compact card can use two columns`() {
val size = CreatorRankingLayoutCalculator.calculate(
parentWidthPx = 374,
horizontalGapPx = 4,
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 2)
)
assertEquals(185, size.widthPx)
assertEquals(185, size.heightPx)
}
@Test
fun `compact card can use three columns`() {
val size = CreatorRankingLayoutCalculator.calculate(
parentWidthPx = 374,
horizontalGapPx = 4,
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 3)
)
assertEquals(122, size.widthPx)
assertEquals(122, size.heightPx)
}
@Test
fun `horizontal card keeps figma aspect ratio`() {
val size = CreatorRankingLayoutCalculator.calculate(
parentWidthPx = 374,
horizontalGapPx = 4,
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Horizontal, itemsPerRow = 1)
)
assertEquals(374, size.widthPx)
assertEquals(100, size.heightPx)
}
}

View File

@@ -6,45 +6,45 @@ import org.junit.Test
class CreatorRankingPlacementTest {
@Test
fun `rank 1 uses large variant and one item row`() {
fun `rank 1은 top ten view type과 1 item row를 사용한다`() {
val placement = CreatorRankingPlacement.fromRank(1)
assertEquals(CreatorRankingCardVariant.Large, placement.variant)
assertEquals(CreatorRankingViewType.TopTen, placement.viewType)
assertEquals(1, placement.itemsPerRow)
}
@Test
fun `rank 2 to 7 uses compact variant and two item row`() {
fun `rank 2부터 7은 top ten view type과 2 item row를 사용한다`() {
(2..7).forEach { rank ->
val placement = CreatorRankingPlacement.fromRank(rank)
assertEquals(CreatorRankingCardVariant.Compact, placement.variant)
assertEquals(CreatorRankingViewType.TopTen, placement.viewType)
assertEquals(2, placement.itemsPerRow)
}
}
@Test
fun `rank 8 to 10 uses compact variant and three item row`() {
fun `rank 8부터 10은 top ten view type과 3 item row를 사용한다`() {
(8..10).forEach { rank ->
val placement = CreatorRankingPlacement.fromRank(rank)
assertEquals(CreatorRankingCardVariant.Compact, placement.variant)
assertEquals(CreatorRankingViewType.TopTen, placement.viewType)
assertEquals(3, placement.itemsPerRow)
}
}
@Test
fun `rank 11 or greater uses horizontal variant and one item row`() {
listOf(11, 12, 100).forEach { rank ->
fun `rank 11부터 20과 20 초과는 lower view type과 1 item row를 사용한다`() {
listOf(11, 12, 20, 21, 100).forEach { rank ->
val placement = CreatorRankingPlacement.fromRank(rank)
assertEquals(CreatorRankingCardVariant.Horizontal, placement.variant)
assertEquals(CreatorRankingViewType.Lower, placement.viewType)
assertEquals(1, placement.itemsPerRow)
}
}
@Test(expected = IllegalArgumentException::class)
fun `rank less than 1 is invalid`() {
fun `rank 1 미만은 유효하지 않다`() {
CreatorRankingPlacement.fromRank(0)
}
}

View File

@@ -0,0 +1,49 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import org.junit.Assert.assertEquals
import org.junit.Test
class CreatorRankingTextStyleTest {
@Test
fun `top 1 item row는 가장 큰 rank name delta text size를 사용한다`() {
val style = CreatorRankingTextStyle.fromRank(1)
assertEquals(96, style.rankTextSizeSp)
assertEquals(32, style.nameTextSizeSp)
assertEquals(16, style.deltaTextSizeSp)
}
@Test
fun `top 2 item row는 중간 rank name delta text size를 사용한다`() {
listOf(2, 7).forEach { rank ->
val style = CreatorRankingTextStyle.fromRank(rank)
assertEquals(54, style.rankTextSizeSp)
assertEquals(22, style.nameTextSizeSp)
assertEquals(16, style.deltaTextSizeSp)
}
}
@Test
fun `top 3 item row는 작은 rank name delta text size를 사용한다`() {
listOf(8, 10).forEach { rank ->
val style = CreatorRankingTextStyle.fromRank(rank)
assertEquals(36, style.rankTextSizeSp)
assertEquals(14, style.nameTextSizeSp)
assertEquals(14, style.deltaTextSizeSp)
}
}
@Test
fun `lower row는 lower 전용 rank name과 기존 delta amount text size를 사용한다`() {
listOf(11, 20, 21).forEach { rank ->
val style = CreatorRankingTextStyle.fromRank(rank)
assertEquals(40, style.rankTextSizeSp)
assertEquals(18, style.nameTextSizeSp)
assertEquals(16, style.deltaTextSizeSp)
}
}
}

View File

@@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import org.junit.Assert.assertEquals
import org.junit.Test
class CreatorRankingTopCardMarginTest {
@Test
fun `rank 1은 큰 닉네임 margin과 rank group start margin을 사용한다`() {
val margin = CreatorRankingTopCardMargin.fromRank(1)
assertEquals(20, margin.nameHorizontalDp)
assertEquals(24, margin.nameBottomDp)
assertEquals(10, margin.rankGroupStartDp)
}
@Test
fun `rank 2부터 7은 중간 rank group start margin을 사용한다`() {
(2..7).forEach { rank ->
val margin = CreatorRankingTopCardMargin.fromRank(rank)
assertEquals(10, margin.nameHorizontalDp)
assertEquals(10, margin.nameBottomDp)
assertEquals(8, margin.rankGroupStartDp)
}
}
@Test
fun `rank 8부터 10은 작은 rank group start margin을 사용한다`() {
(8..10).forEach { rank ->
val margin = CreatorRankingTopCardMargin.fromRank(rank)
assertEquals(10, margin.nameHorizontalDp)
assertEquals(10, margin.nameBottomDp)
assertEquals(6, margin.rankGroupStartDp)
}
}
@Test(expected = IllegalStateException::class)
fun `top-card 범위 밖 rank는 지원하지 않는다`() {
CreatorRankingTopCardMargin.fromRank(11)
}
}

View File

@@ -54,7 +54,7 @@
- `FollowRecommendedCreatorsRequest(val creatorIds: List<Long>)`
- `HomeRecommendationResponse`와 PRD의 모든 item DTO
- 주의: 서버 필드명은 임의 변경하지 않고 필요할 때만 `@SerializedName`을 추가한다.
- 검증: `HomeRecommendationResponse` 필드가 `lives`, `banners`, `recentlyActiveCreators`, `recentDebutCreators`, `firstAudioContents`, `aiCharacters`, `genreCreators`, `cheerCreators`, `popularCommunityPosts`모두 포함한다.
- 검증: `HomeRecommendationResponse` 필드가 현재 계약 기준 `lives`, `banners`, `recentlyActiveCreators`, `recentDebutCreators`, `aiCharacters`, `genreCreators`, `cheerCreators`, `popularCommunityPosts`포함하고 `firstAudioContents`는 포함하지 않는다.
- [x] **Task 2.2: Repository 생성**
- 생성: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/data/HomeRecommendationRepository.kt`
@@ -121,7 +121,6 @@
- 구현 내용:
- `Loading`, `Content`, `Empty`, `Error` 상태
- 섹션별 UI model 또는 adapter item
- `HomeFirstAudioContentItem` -> `AudioContentTag.First`, `Point`, `Free` 조건 매핑
- `HomeAiCharacterItem.originalWorkTitle == null`이면 기존 `CharacterChatThumbnailView``shouldShowOriginalTitle = false`
- `HomePopularCommunityPostItem``price`, `existOrdered` 기반 유료 상태 모델
- 검증: DTO를 Fragment/ViewHolder에 직접 노출하지 않는다.
@@ -275,6 +274,19 @@
- `rv_home_recent_debut_creators` 시작 padding `14dp`, item gap `4dp` 적용
- 검증: `HomeMainFragmentLayoutTest`에 recent debut 카드 치수와 목록 간격 회귀 테스트를 추가한다.
- [x] **Task 6.8.1: 최근 데뷔한 크리에이터 item 축소 및 root clipping 보완**
- 기준: Figma `24:5534`, item `24:5537`
- 수정: `docs/20260601_메인_홈_추천_UI와_API_연동/prd.md`
- 수정: `docs/20260601_메인_홈_추천_UI와_API_연동/plan-task.md`
- 수정: `app/src/main/res/layout/item_home_recent_debut_creator.xml`
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeRecentDebutCreatorAdapter.kt`
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt`
- 구현 내용:
- item root를 기존 `205dp x 259dp` 비율 기준 `185dp x 234dp`로 축소한다.
- `ImageView`는 root 전체를 채우도록 `match_parent`로 맞춘다.
- adapter ViewHolder에서 item root에 `clipToOutline=true``ViewOutlineProvider.setRoundRect(..., radius_14)`를 적용해 이미지, dim gradient, 닉네임 전체가 `14dp` radius 안에서 잘리게 한다.
- 검증: `HomeMainFragmentLayoutTest`의 recent debut 카드 테스트를 먼저 `185dp x 234dp`, adapter root clipping 기대값으로 바꿔 RED를 확인한 뒤 동일 테스트를 GREEN으로 전환한다.
- [x] **Task 6.9: 처음부터 함께 성장! Figma 정합 수정**
- 기준: Figma `24:5539`
- 생성: `app/src/main/res/layout/item_home_first_audio_content.xml`
@@ -651,6 +663,9 @@
- 2026-06-02: Figma `24:5534` 기준 `최근 데뷔한 크리에이터` 섹션을 확인해 기존 `112dp` 세로 원형 profile item이 디자인과 다름을 확인했다. `HomeMainFragmentLayoutTest`에 recent debut 카드 치수와 목록 간격 테스트를 먼저 추가했고, 기존 구현에서 `recent debut creator item matches figma card dimensions`, `home recent debut section matches figma list spacing` 두 테스트가 실패하는 RED 상태를 확인했다.
- 2026-06-02: `item_home_recent_debut_creator.xml``205dp x 259dp` 이미지 카드 구조로 변경하고, radius `14dp` 배경/하단 dim gradient/`24sp` bold 중앙 닉네임을 적용했다. `HomeRecentDebutCreatorAdapter`에는 최근 데뷔 전용 `4dp` item gap을 적용했고, `rv_home_recent_debut_creators` 시작 padding을 Figma 기준 `14dp`로 맞췄다. 동일 targeted 테스트 재실행 결과 BUILD SUCCESSFUL을 확인했다.
- 2026-06-02: 최근 데뷔 수정 후 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`는 BUILD SUCCESSFUL을 확인했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`는 이번 최근 데뷔 테스트는 통과했지만 기존 `home live section matches figma row dimensions``rv_home_lives` 높이 기대값(`102dp`)과 현재 XML `wrap_content`가 맞지 않아 실패했다. 이후 사용자 확인에 따라 `rv_home_lives` 높이는 아이템 높이를 따라가야 하므로 `wrap_content`가 올바른 계약으로 정정했다.
- 2026-06-29: 사용자 추가 요청에 따라 Figma `24:5534`, `24:5537` 기준 최근 데뷔 item의 전체 rounded clipping과 축소 치수를 보완했다. `HomeMainFragmentLayoutTest`의 recent debut 테스트를 `185dp x 234dp`, root `clipToOutline`, image `match_parent` 기대값으로 먼저 변경했고, 기존 `205dp x 259dp` 구현에서 `HomeMainFragmentLayoutTest.kt:232` assertion 실패로 RED를 확인했다.
- 2026-06-29: GREEN 구현으로 `item_home_recent_debut_creator.xml` root를 `185dp x 234dp`로 축소하고 image를 `match_parent`로 변경했다. 저장소 clipping 패턴에 맞춰 `HomeRecentDebutCreatorAdapter` ViewHolder에서 item root에 `clipToOutline=true``ViewOutlineProvider.setRoundRect(..., radius_14)`를 적용해 image, dim gradient, nickname 전체가 같은 radius로 잘리게 했다. 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest.recent debut creator item matches figma card dimensions" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest.home recent debut section matches figma list spacing"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`, `./gradlew :app:mergeDebugResources :app:compileDebugKotlin :app:ktlintCheck`가 모두 BUILD SUCCESSFUL임을 확인했다. `lsp_diagnostics`는 Kotlin/XML LSP 도구가 이 환경에 제공되지 않아 Gradle compile/test/ktlint로 보완했다.
- 2026-06-29: 리뷰에서 adapter가 새 `RecyclerView.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)`를 만들어 XML root의 `185dp x 234dp` 치수 계약을 실제 RecyclerView 경로에서 약화할 수 있다는 차단 이슈를 확인했다. `HomeRecentDebutCreatorAdapter`가 inflate된 root layout params를 `RecyclerView.LayoutParams(existing)`로 보존하고 `marginEnd`만 추가하도록 수정했으며, `HomeMainFragmentLayoutTest`의 adapter 경로 검증에 `185dp x 234dp` assertion을 추가했다. 재검증으로 targeted recent debut tests, 전체 `HomeMainFragmentLayoutTest`, `./gradlew :app:mergeDebugResources :app:compileDebugKotlin :app:ktlintCheck`가 모두 BUILD SUCCESSFUL임을 확인했다. 보안 리뷰가 지적한 `app/build.gradle release debuggable true`는 현재 워크트리에 존재하는 이번 작업 범위 밖 변경으로, 사용자 승인 없이 수정하지 않았다.
- 2026-06-02: Figma `24:5539` 기준 `처음부터 함께 성장!` 섹션을 확인해 기존 `AudioContentCardView` 기반 item이 전용 profile row 구조와 item gap을 재현하기 어렵다는 점을 확인했다. `HomeMainFragmentLayoutTest`에 first audio 전용 item 치수, 목록 간격, tag visibility 테스트를 먼저 추가했고, 구현 전 `item_home_first_audio_content`와 관련 view ID 미존재로 RED 컴파일 실패를 확인했다.
- 2026-06-02: `item_home_first_audio_content.xml`을 추가하고 `HomeFirstAudioAdapter``AudioContentCardView` 대신 전용 item을 inflate하도록 변경했다. 썸네일은 `185dp x 185dp`, creator profile은 `42dp x 42dp`, item gap은 `4dp`로 맞췄고, `First`/`Point`/`Free` tag visibility는 기존 `tags` 모델로 바인딩했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"` 재실행 결과 BUILD SUCCESSFUL을 확인했다.
- 2026-06-02: 리뷰에서 first audio 썸네일 이미지 clipping과 `Free` tag visibility 테스트 누락이 차단 이슈로 지적되어 `item_home_first_audio_content.xml``clipToOutline="true"`를 추가하고 `HomeMainFragmentLayoutTest`에서 `First`/`Point`/`Free` 개별 tag visibility를 모두 검증하도록 보강했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `./gradlew :app:testDebugUnitTest`를 실행했고 모두 BUILD SUCCESSFUL을 확인했다.
@@ -700,3 +715,24 @@
- 2026-06-05: Phase 9.4 리뷰에서 `ImageLoaderProviderTest`가 상수명 차이만 검증해 `init()`의 legacy cache 삭제와 신규 cache 생성 계약을 충분히 고정하지 못한다는 차단 이슈를 확인했다. 테스트를 보강해 Robolectric 환경에서 `cacheDir/image_cache/journal`을 만든 뒤 `ImageLoaderProvider.init(context)` 호출 후 legacy directory 삭제와 `coil_image_cache` 생성까지 검증하도록 수정했다. 보강 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.common.ImageLoaderProviderTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`를 재실행했고 모두 BUILD SUCCESSFUL을 확인했다.
- 2026-06-05: Phase 9.5로 `BannerView.setItems()` 직후 앱이 멈추는 문제를 배너 모델 payload 누락과 `BannerAdapter`의 huge range notify 가능성으로 분리했다. `BannerItem`을 삭제하지 않고 `HomeRecommendationBannerUiModel`과 동일한 payload(`imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link`)를 담도록 확장해 공용 위젯 독립성을 유지했으며, `HomeBannerBinder`는 synthetic id 변환 없이 홈 배너 UI model과 `BannerItem`을 변환하도록 단순화했다. 또한 carousel 목록에서 `itemCount == Int.MAX_VALUE`여도 `notifyItemRangeInserted(0, Int.MAX_VALUE)``notifyItemRangeChanged(0, Int.MAX_VALUE)`를 호출하지 않도록 했고, 단일 배너는 specific notify를 쓰되 `Int.MAX_VALUE` 가상 adapter 상태에서만 `NotifyDataSetChanged` lint를 좁은 helper에 제한했다. 구현 전 `BannerViewTest`는 기존 `BannerItem`이 변경된 홈 배너 payload를 담지 못해 클릭 데이터 보존을 검증할 수 없는 RED 상태였고, 구현 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`가 BUILD SUCCESSFUL임을 확인했다.
- 2026-06-05: Phase 9.5 회귀 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`를 추가 실행했고 모두 BUILD SUCCESSFUL을 확인했다. `ktlintCheck`에서는 기존 `.editorconfig disabled_rules` deprecation warning만 출력됐고 lint 실패는 없었다.
### Phase 12: `처음부터 함께 성장!` 섹션 제거
- [x] **Task 10.1: 홈 추천 Response와 UI에서 firstAudio 제거**
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/data/HomeRecommendationModels.kt`
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationMappers.kt`
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt`
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiState.kt`
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
- 삭제: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeFirstAudioAdapter.kt`
- 삭제: `app/src/main/res/layout/item_home_first_audio_content.xml`
- 검증 기록: 2026-06-29 기존 코드에서 `rvHomeFirstAudioContents`, `llHomeFirstAudioSection`, `viewHomeFirstAudioTitle` binding 미해결로 RED 컴파일 실패를 확인했다. 제거 후 targeted unit test와 compile이 포함된 Gradle 실행이 BUILD SUCCESSFUL임을 확인했다.
- [x] **Task 10.2: 홈 first audio 전체보기와 리소스 제거**
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/data/ContentOverviewModels.kt`
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/model/ContentOverviewUiModels.kt`
- 수정: `app/src/main/res/values/strings.xml`, `values-en/strings.xml`, `values-ja/strings.xml`
- 검증 기록: 2026-06-29 `ContentOverviewType.FIRST_AUDIO_CONTENT``home_recommendation_section_first_audio_contents` production 참조를 제거하고, `NEW_AND_HOT_AUDIO` 경로는 유지했다.
## Verification Log
- 2026-06-29: `처음부터 함께 성장!` 제거 회귀 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*" --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*" :app:mergeDebugResources :app:compileDebugKotlin :app:ktlintCheck`를 실행했고 BUILD SUCCESSFUL을 확인했다. `git diff --check` 출력 없음도 확인했다. Android 기기/에뮬레이터 접근이 없어 실제 화면 조작은 수행하지 못했으며, `rg``app/src/main`의 first audio/FIRST_AUDIO_CONTENT production 참조 제거를 확인했다. 리뷰 게이트에서 차단 이슈 없이 승인받았다.

View File

@@ -68,7 +68,6 @@ data class HomeRecommendationResponse(
val banners: List<HomeBannerItem>,
val recentlyActiveCreators: List<HomeActiveCreatorItem>,
val recentDebutCreators: List<HomeCreatorItem>,
val firstAudioContents: List<HomeFirstAudioContentItem>,
val aiCharacters: List<HomeAiCharacterItem>,
val genreCreators: List<HomeGenreCreatorGroupItem>,
val cheerCreators: List<HomeCreatorItem>,
@@ -88,16 +87,6 @@ data class HomeRecommendationResponse(
#### Changed Item Contracts
```kotlin
data class HomeFirstAudioContentItem(
val contentId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileImage: String,
val title: String,
val price: Int,
val coverImage: String?,
val isPointAvailable: Boolean
)
data class HomeAiCharacterItem(
val characterId: Long,
@@ -126,8 +115,6 @@ data class HomePopularCommunityPostItem(
```
#### Changed Item Requirements
- `HomeFirstAudioContentItem.price`는 첫 오디오 콘텐츠의 무료 여부 표시 정책에 사용한다.
- `HomeFirstAudioContentItem.isPointAvailable`은 첫 오디오 콘텐츠 카드 바인딩 시 포인트 사용 가능 표시 정책에 반영한다.
- `HomeAiCharacterItem.creatorId``크리에이터와 이야기를 나눠요!` 섹션 item 터치 시 크리에이터 채널 이동 값으로 사용한다.
- `HomeAiCharacterItem.characterId``CharacterChatThumbnailView` 표시 모델의 캐릭터 식별 값으로 유지하되, 추천 홈 item 터치 목적지로는 사용하지 않는다.
- `HomeAiCharacterItem.profileImage``CharacterChatThumbnailView`의 실제 이미지 URL로 사용한다.
@@ -241,18 +228,6 @@ data class FollowRecommendedCreatorsRequest(
- 유료이고 구매하지 않은 UI: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=309-19774&m=dev
- 유료인데 구매함 또는 무료 UI: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=309-19775&m=dev
#### AudioContentCardView Requirements
- 첫 오디오 콘텐츠는 `v2.widget.AudioContentCardView`를 사용한다.
- `HomeFirstAudioContentItem.isPointAvailable == true`이면 `ic_content_tag_point`를 쓰는 `ImageView`를 표시한다.
- `HomeFirstAudioContentItem`에서는 오리지널 여부를 판단하지 않는다.
- 오리지널 판단 변수가 없으므로 `ic_content_tag_original`을 쓰는 `ImageView`는 항상 `GONE` 처리한다.
- `HomeFirstAudioContentItem.price == 0`이면 무료 콘텐츠로 판단하고 `무료` TextView를 `VISIBLE`로 표시한다.
- `HomeFirstAudioContentItem.price > 0`이면 `무료` TextView를 `GONE` 처리한다.
- `HomeFirstAudioContentItem`으로 표시하는 아이템은 모두 크리에이터의 첫 콘텐츠이므로 `FIRST` 글자를 포함하는 `LinearLayout`을 항상 표시한다.
- 위 조건에 해당하지 않는 태그는 모두 `GONE` 처리한다.
- 기존 `AudioContentCardView``AudioContentTag.Original`, `AudioContentTag.Point`, `AudioContentTag.First`, `AudioContentTag.Free` 계약을 우선 재사용한다.
- `HomeFirstAudioContentItem` 응답에는 오리지널 여부 필드가 없으므로 오리지널 태그는 표시하지 않는다.
### 섹션 구성과 UI 재사용 도식
Figma `24:5514` 기준 홈 추천 화면은 아래 순서로 배치한다. 제외 섹션은 구현하지 않는다.
@@ -277,7 +252,7 @@ HomeRecommendation 화면
├─ 최근 데뷔한 크리에이터: recentDebutCreators
│ ├─ Figma instance: section-title, creater
│ ├─ 재사용: view_section_title
│ └─ 신규: 최근 데뷔 크리에이터 카드 UI
│ └─ 신규: 최근 데뷔 크리에이터 카드 UI, item root `185dp x 234dp`, radius `14dp` 전체 clip
├─ 첫 오디오 콘텐츠: firstAudioContents
│ ├─ Figma instance: contents, profile
│ ├─ 재사용: v2.widget.AudioContentCardView
@@ -312,7 +287,6 @@ HomeRecommendation 화면
| `banners` | `24:5525` | 배너 캐러셀 | `BannerView` | 없음 |
| `recentlyActiveCreators` | `24:5529` | 방금 활동한 크리에이터 | `view_section_title` | `list-act` 신규 필요 |
| `recentDebutCreators` | `24:5534` | 최근 데뷔한 크리에이터 | `view_section_title` | 신규 카드 필요 |
| `firstAudioContents` | `24:5539` | 첫 오디오 콘텐츠 | `AudioContentCardView` | 태그 조건 매핑 확인 필요 |
| `aiCharacters` | `24:5551` | AI 캐릭터 | `view_section_title`, `CharacterChatThumbnailView` | DTO mapping 필요 |
| `genreCreators` | `24:5611`, `24:5636` 후보 | 장르별 크리에이터 | `view_section_title` | 그룹 카드, 모두 팔로우 버튼 신규 필요 |
| `cheerCreators` | `24:5636` 후보 | 최근 응원이 많은 크리에이터 | `view_section_title` | profile grid, 모두 팔로우 버튼 신규 필요 |
@@ -348,6 +322,7 @@ HomeRecommendation 화면
- 세로 스크롤 중에도 `TextTabBarView`가 화면에 남아 있어야 하므로, 스크롤 컨테이너는 `TextTabBarView` 아래 content 영역에만 적용한다.
- 상단 라이브 목록, 배너, 추천 카드들은 어두운 홈 배경 위에서 기존 v2 widget 색상/typography/radius를 유지한다.
- 각 섹션 제목은 기존 `view_section_title`을 사용한다.
- `최근 데뷔한 크리에이터` item은 기존 `205dp x 259dp` 비율을 유지해 `185dp x 234dp`로 축소하고, 이미지뿐 아니라 dim gradient와 닉네임을 포함한 item root 전체가 `14dp` rounded corner로 잘려야 한다.
- `최근 응원이 많은 크리에이터`, `장르별 크리에이터`의 모두 팔로우 버튼은 success 전/후 상태가 명확히 구분되어야 한다.
- 인기 커뮤니티는 keyword 없이 본문, 선택적 이미지, 유료 잠금 상태, reaction row를 보여야 한다.
- 유료 미구매 커뮤니티 이미지는 내용을 바로 읽을 수 없도록 blur/lock overlay가 적용되어야 한다.
@@ -404,6 +379,7 @@ HomeRecommendation 화면
---
## 12. 검증 기록
- 2026-06-29: 사용자 추가 요청에 따라 `최근 데뷔한 크리에이터` item을 기존 `205dp x 259dp` 비율로 축소한 `185dp x 234dp`로 표시하고, 이미지 단독이 아니라 item root 전체에 `14dp` rounded clipping을 적용해야 한다는 요구사항을 PRD에 반영했다. Figma `24:5534`, `24:5537` design context와 screenshot을 확인했다.
- 2026-06-23: 기존 `CreatorChannelFanTalkFragmentLayoutTest.kt:78` ktlint 줄바꿈 위반을 동작 변경 없이 수정한 뒤 `./gradlew :app:testDebugUnitTest`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:ktlintCheck`가 모두 BUILD SUCCESSFUL임을 재확인했다. `adb devices` 결과 연결된 Android 기기가 없어 실제 기기 수동 확인은 수행하지 못했고, 해당 수동 확인은 환경 차단으로 계획 문서에 남겼다.
- 2026-06-23: Phase 12 검증으로 `./gradlew :app:testDebugUnitTest`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:mergeDebugResources`는 BUILD SUCCESSFUL을 확인했다. `./gradlew :app:ktlintCheck`는 이번 변경 범위 밖의 `CreatorChannelFanTalkFragmentLayoutTest.kt:78` 기존 줄 길이/인자 줄바꿈 위반으로 실패했으며, 해당 파일은 이번 diff에 포함되지 않음을 확인했다.
- 2026-06-23: `HomeAiCharacterItem.creatorId` 응답 계약을 실제 DTO/UI model/mapper에 반영하고, `크리에이터와 이야기를 나눠요!` item 터치 시 `CharacterDetailActivity` 대신 `CreatorChannelActivity`로 이동하도록 구현했다. `creatorId <= 0L`은 route를 만들지 않아 이동하지 않고, `characterId``CharacterChatThumbnailItem.characterId` 표시 계약에 유지한다. 관련 RED/GREEN targeted test와 `HomeMainFragmentLayoutTest` 전체 회귀 테스트가 BUILD SUCCESSFUL임을 확인했다.
@@ -434,3 +410,9 @@ HomeRecommendation 화면
- 2026-06-05: 유료 미구매 item 재바인딩 시 이전 Coil 요청이 뒤늦게 완료될 가능성을 방지하기 위해 locked image 경로에서 기존 Coil 요청을 `dispose()`로 취소한 뒤 drawable을 비우도록 추가 보완했다.
- 2026-06-05: 인기 커뮤니티 Phase 7 UI를 실제 기기에서 확인할 수 있도록 `HomeMainFragment`의 임시 샘플 content에 무료/유료 미구매/구매 완료 커뮤니티 3건을 추가했다. 관련 컴파일, 홈 레이아웃 테스트, ktlint, resource merge는 성공했으나 연결 기기가 없어 `installDebug` 기반 실기기 확인은 수행하지 못했다.
- 2026-06-05: 커뮤니티 게시물 이미지가 고정 크기 때문에 잘려 보이는 문제를 수정했다. `view_feed_community.xml`에서 root와 이미지 container의 고정 폭/높이를 제거하고, `FeedCommunityView`에서 실제 card content width 기준 `346:236` 비율로 이미지 영역 높이를 계산하도록 변경했다. 관련 Feed/Home 테스트, compile, resource merge, ktlint가 성공했다.
### 2026-06-29 변경: `처음부터 함께 성장!` 섹션 제거
- 홈 추천 탭에서 `처음부터 함께 성장!` 섹션을 제거한다.
- `GET /api/v2/home/recommendations` 응답 계약에서 `firstAudioContents``HomeFirstAudioContentItem`을 제거한다.
- UI model, mapper, Fragment binding, adapter, item layout, section title string도 함께 제거한다.
- 다른 섹션(`recentDebutCreators`, `aiCharacters`, `genreCreators`, `cheerCreators`, `popularCommunityPosts`)의 노출/매핑 계약은 유지한다.

View File

@@ -10,6 +10,21 @@
---
## 후속 Rewrite 전제
- 후속 구현에서는 크리에이터 랭킹 view를 기존 view 부분 보정이 아니라 새 view로 다시 작성한다.
- 신규 크리에이터 랭킹 view는 `ConstraintLayout`을 사용하고 `FrameLayout`을 사용하지 않는다.
- Figma 기준은 1 per row `24:5659`, 2 per row `24:5660`, 3 per row `24:5668`, 11~20 lower row `24:5670`이다.
- adapter view type은 ranks `1~10`, ranks `11~20` 두 개만 둔다.
- `rank > 20`은 이번 문서 갱신 및 후속 rewrite 계획에서 mapper/API 동작을 변경하지 않는다. 별도 요구가 있기 전까지 현재 lower-row 처리 방식은 유지할 수 있다.
- rank change와 `NEW` 표시 로직은 `CreatorRankingDeltaPresentation``HomeCreatorRankingMappers` 의미를 유지한다.
- ranks `1~10` 텍스트 크기 정책은 1 item row rank/name/delta `96sp/32sp/16sp`, 2 item row `54sp/22sp/16sp`, 3 item row `36sp/14sp/14sp`이다.
- ranks `11~20`은 Figma `24:5670` lower-row 기준으로 rank `40sp`, name `18sp`를 사용한다.
- XML/Kotlin에서 layout sizing 목적으로 고정 width/height를 새로 도입하지 않는다. `wrap_content`, `match_parent`, margins, padding, constraints, match-constraint, 측정된 available width, aspect handling을 필요에 따라 사용한다.
- 테스트는 placement, text-style model, mapper, delta presentation, item behavior 같은 logic contract에만 작성한다.
- view size, margin, padding, constraints, `TextView` dimensions, inflated layout params, visibility attributes를 검증하는 UI layout/visual test는 작성하지 않는다.
---
## 전제와 성공 기준
- PRD: `docs/20260608_크리에이터_랭킹_페이지/prd.md`
- Figma: `24:5654`
@@ -375,7 +390,497 @@
---
### Phase 10: 메인 홈 랭킹 탭 Figma 최신 시각 보정
- [x] **Task 10.1: Figma 최신 텍스트 크기와 item 간격 RED 테스트 추가**
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapterLayoutTest.kt`
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLayoutCalculatorTest.kt`
- 구현 내용:
- Figma `24:5668` 기준 3열 Compact 순위 `36sp`, 닉네임 `14sp`, rank-num 숫자 `14sp`를 검증한다.
- Figma `24:5661` 기준 2열 Compact 순위 `54sp`, 닉네임 `22sp`, rank-num 숫자 `16sp`를 회귀 검증한다.
- Figma `24:5670` 기준 11위 이후 Horizontal rank group 내부 세로 배치와 New/순위 변동 표시를 검증한다.
- 랭킹 item 간격 `8dp` 기준으로 2열/3열 item 폭 계산을 검증한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLayoutCalculatorTest"`
- 기대 결과: production 수정 전 3열 Compact 순위/변동 텍스트 크기와 item 간격 assertion이 실패한다.
- 검증 기록: production 수정 전 `CreatorRankingAdapterLayoutTest`를 실행해 3열 Compact 순위 텍스트 크기 assertion 실패를 확인했다. `CreatorRankingLayoutCalculatorTest`는 계산기에 `8dp`를 직접 전달하는 순수 계산 테스트라 기존 코드에서도 통과했으며, Adapter의 실제 간격 상수는 Task 10.2에서 함께 보정했다.
- [x] **Task 10.2: Compact 텍스트 크기와 랭킹 item 간격 보정**
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingCompactCardView.kt`
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapter.kt`
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
- 구현 내용:
- 3열 Compact 순위 텍스트를 `36sp`, rank-num 숫자를 `14sp`로 보정한다.
- 2열 Compact 순위 `54sp`, 닉네임 `22sp`, rank-num 숫자 `16sp`는 유지한다.
- 랭킹 item 폭 계산 기준과 실제 RecyclerView item decoration 간격을 `8dp`로 보정한다.
- 11위 이후 Horizontal rank group의 기존 세로 배치와 New badge 크기는 유지한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLayoutCalculatorTest"`
- 기대 결과: RED 테스트가 `BUILD SUCCESSFUL`로 전환된다.
- 검증 기록: `CreatorRankingCompactCardView`에서 3열 Compact 순위 `36sp`, rank-num 숫자 `14sp`를 적용하고 2열 Compact rank-num 숫자 `16sp`를 명시 유지했다. `CreatorRankingAdapter`의 폭 계산 상수를 `8dp`로 보정하고, 실제 `GridLayoutManager` 배치에서도 인접 item offset 합이 `8dp`가 되도록 전용 `CreatorRankingItemDecoration`을 추가했다. `HomeMainFragment`에는 중복 추가 방지 조건과 함께 decoration을 연결했다. 병렬 Gradle 실행 중 Kotlin incremental cache 충돌이 발생해 `./gradlew --stop`, `./gradlew clean` 후 순차 재실행했고, `CreatorRankingAdapterLayoutTest`, `CreatorRankingLayoutCalculatorTest`, `HomeMainFragmentLayoutTest.home ranking fragment wires adapter and grid layout manager`가 모두 `BUILD SUCCESSFUL`로 통과했다.
- [x] **Task 10.3: 후속 검증과 문서 기록 누적**
- 실행:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`
- `./gradlew :app:mergeDebugResources`
- `./gradlew :app:compileDebugKotlin`
- `./gradlew :app:ktlintCheck`
- 수정: `docs/20260608_크리에이터_랭킹_페이지/prd.md`
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 기대 결과: 신규 변경 관련 테스트와 빌드가 통과하고, ktlint 기존 전역 위반이 있으면 Verification Log에 누적한다.
- 검증 기록: `creatorranking.*`, `mergeDebugResources`, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`를 실행했다. 최초 `ktlintCheck`에서 변경 파일인 `CreatorRankingAdapter.kt` 긴 줄 1건과 기존 전역 위반이 함께 보고되어 변경 파일 줄바꿈만 수정했고, 이후 `ktlintCheck``BUILD SUCCESSFUL`로 통과했다.
---
### Phase 11: rank-area wrap_content 계약 보정
- [x] **Task 11.1: PRD/계획 문서에 후속 계약 반영**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/prd.md`
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 구현 내용:
- 순위 숫자 `TextView`의 XML/Kotlin width/height는 모든 variant에서 `wrap_content`를 유지한다.
- Kotlin은 순위 숫자/닉네임/rank-num 텍스트 크기와 rank area의 spacing/margin만 조정한다.
- 순위 숫자와 rank-num은 Large/Compact/Horizontal의 rank area 안에서 함께 가운데 정렬한다.
- 검증: 구현 전 문서에 Phase 11 후속 범위와 성공 기준을 한국어로 기록한다.
- 검증 기록: 구현 전 PRD와 plan-task에 rank `TextView` `wrap_content` 유지, Kotlin의 텍스트 크기/spacing/margin 조정 범위, rank와 rank-num의 rank area 중앙 정렬 계약을 기록했다.
- [x] **Task 11.2: rank-area 계약 테스트 갱신**
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapterLayoutTest.kt`
- 구현 내용:
- Large, Compact medium, Compact small, Horizontal의 `tv_creator_ranking_rank` width/height가 `ViewGroup.LayoutParams.WRAP_CONTENT`인지 검증한다.
- 순위 `TextView``Gravity.CENTER`, `includeFontPadding=false` 계약을 유지한다.
- Compact medium 순위 `54sp`/닉네임 `22sp`/rank-num `16sp`, Compact small 순위 `36sp`/닉네임 `14sp`/rank-num `14sp` 계약을 유지한다.
- Horizontal은 `ll_creator_ranking_rank_group``LinearLayout.VERTICAL`, `Gravity.CENTER`, rank child `wrap_content`를 유지하는지 검증한다.
- Large/Compact는 rank group 안에서 순위와 rank-num이 함께 중앙 정렬되는지 검증한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`
- 기대 결과: production 수정 전 고정 width/height assertion 변경에 따라 실패한다.
- 검증 기록: production 수정 전 실행 결과 Large/Compact는 `ll_creator_ranking_rank_group` 미구현으로 실패했고, Horizontal은 rank `TextView` 고정 layoutParams 때문에 `wrap_content` assertion이 실패했다.
- [x] **Task 11.3: Large/Compact/Horizontal rank-area 최소 구현**
- 수정: `app/src/main/res/layout/view_creator_ranking_large_card.xml`
- 수정: `app/src/main/res/layout/view_creator_ranking_compact_card.xml`
- 수정: `app/src/main/res/layout/view_creator_ranking_horizontal_card.xml`
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLargeCardView.kt`
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingCompactCardView.kt`
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingHorizontalCardView.kt`
- 구현 내용:
- Large/Compact는 순위 숫자와 `ll_creator_ranking_delta`를 rank group으로 묶고 group을 rank area 중앙 정렬 기준으로 배치한다.
- Horizontal은 기존 rank group 세로/중앙 정렬을 유지하되 rank `TextView` 고정 width/height 설정을 제거한다.
- Kotlin에서 `tv_creator_ranking_rank`에 고정 width/height를 설정하지 않고, 텍스트 크기와 rank/delta spacing만 조정한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`
- 기대 결과: 갱신된 rank-area 계약 테스트가 `BUILD SUCCESSFUL`로 통과한다.
- 검증 기록: Large/Compact XML에 rank group을 추가해 순위 숫자와 `ll_creator_ranking_delta`를 함께 세로 중앙 정렬했고, Large/Compact/Horizontal Kotlin에서 rank `TextView` 고정 width/height 설정을 제거했다. 동일 `CreatorRankingAdapterLayoutTest``BUILD SUCCESSFUL`로 통과했다.
- [x] **Task 11.4: 후속 검증과 문서 기록 누적**
- 실행:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`
- `./gradlew :app:mergeDebugResources`
- `./gradlew :app:compileDebugKotlin`
- 수정: `docs/20260608_크리에이터_랭킹_페이지/prd.md`
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 기대 결과: 모두 `BUILD SUCCESSFUL`, Verification Log에 한국어로 결과 누적.
- 검증 기록: `CreatorRankingAdapterLayoutTest`, `mergeDebugResources`, `compileDebugKotlin`이 모두 `BUILD SUCCESSFUL`로 통과했다. `rg`로 Kotlin 내 rank `TextView` 고정 sizing 잔여 없음과 XML `wrap_content` 유지를 확인했다.
### Phase 12: 8~10위 Compact small 순위 표시 보정
- [x] **Task 12.1: 8~10위 Compact small 문제 원인 확인과 문서 반영**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/prd.md`
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 구현 내용:
- `tv_creator_ranking_rank` 자체는 `wrap_content`지만, Compact small parent `ll_creator_ranking_rank_group` 폭이 `52 * scale`로 고정되어 `10` 표시가 답답해질 수 있음을 기록한다.
- 8~10위 Compact small rank group은 `wrap_content`로 유지한다.
- 8~10위 Compact small rank와 rank-num 간격은 Kotlin margin으로 더 좁힌다.
- 8~10위 Compact small 닉네임은 14sp보다 작게 표시한다.
- 검증 기록: 직접 파일 확인과 병렬 explore 결과 모두 `CreatorRankingCompactCardView.positionSmall()`의 rank group 고정 폭을 핵심 원인으로 지목했다. 구현 전 PRD와 plan-task에 Phase 12 범위와 성공 기준을 반영했다.
- [x] **Task 12.2: Compact small rank=10/parent wrap_content RED 테스트 갱신**
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapterLayoutTest.kt`
- 구현 내용:
- 실제 3열 카드 폭에 가까운 `119x119` 조건에서 `rank=10` bind 후 순위 텍스트가 `10`인지 검증한다.
- Compact small rank group width가 `WRAP_CONTENT`인지 검증한다.
- Compact small 닉네임 텍스트 크기가 `13sp`인지 검증한다.
- Compact small rank-num top margin이 음수 보정되어 rank와 rank-num 간격이 줄어드는지 검증한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`
- 기대 결과: production 수정 전 rank group fixed width, 닉네임 14sp, rank-num margin 미보정으로 실패한다.
- 검증 기록: production 수정 전 실행해 Compact small rank group width assertion 실패를 확인했다.
- [x] **Task 12.3: Compact small rank group/간격/닉네임 최소 보정**
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingCompactCardView.kt`
- 구현 내용:
- Compact small에서 rank group width를 `WRAP_CONTENT`로 변경한다.
- Compact small 닉네임 텍스트 크기를 `13sp`로 줄인다.
- Compact small rank-num top margin을 scale 기반 음수 margin으로 설정해 rank와 rank-num 간격을 좁힌다.
- Large, Compact medium, Horizontal, API/ViewModel/Adapter 동작은 변경하지 않는다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`
- 기대 결과: 갱신된 테스트가 `BUILD SUCCESSFUL`로 통과한다.
- 검증 기록: `CreatorRankingCompactCardView.positionSmall()`에서 rank group width를 `WRAP_CONTENT`로 변경하고, 닉네임을 `13sp`로 줄였으며, rank-num top margin을 `-4 * scale`로 보정했다. 동일 테스트가 `BUILD SUCCESSFUL`로 통과했다.
- [x] **Task 12.4: 후속 검증과 문서 기록 누적**
- 실행:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`
- `./gradlew :app:mergeDebugResources`
- `./gradlew :app:compileDebugKotlin`
- `./gradlew :app:ktlintCheck`
- 수정: `docs/20260608_크리에이터_랭킹_페이지/prd.md`
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 기대 결과: 모두 `BUILD SUCCESSFUL`, Verification Log에 한국어로 결과 누적.
- 검증 기록: `CreatorRankingAdapterLayoutTest`, `mergeDebugResources`, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`가 모두 통과했다. 병렬 Gradle 실행 중 Kotlin incremental cache 충돌이 있었으나 `./gradlew --stop` 후 순차 재실행으로 통과했다.
---
### Phase 13: Rewrite 문서 요구사항 정리
- [x] **Task 13.1: PRD에 rewrite scope와 제약 추가**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/prd.md`
- 구현 내용:
- 크리에이터 랭킹 view를 새로 작성하는 후속 범위를 추가한다.
- 신규 view는 `ConstraintLayout`을 사용하고 `FrameLayout`을 사용하지 않는다고 명시한다.
- adapter view type은 ranks `1~10`, ranks `11~20` 두 개만 둔다고 명시한다.
- `rank > 20`은 이번 문서 갱신에서 mapper/API 동작을 바꾸지 않고, 별도 요구 전까지 현재 lower-row 처리를 유지할 수 있다고 기록한다.
- 검증: PRD에 production code 변경 없이 rewrite 요구사항만 반영되어 있다.
- [x] **Task 13.2: plan-task에 Phase 13 이후 구현 계획 추가**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 구현 내용:
- Phase 13 이후를 unchecked 상태로 추가한다.
- 단계 순서는 docs, logic-only test rewrite, placement/text-style logic, adapter two-view rewrite, ConstraintLayout view rewrite, obsolete FrameLayout cleanup, home integration cleanup, final validation이다.
- 기존 Verification Log는 삭제하거나 덮어쓰지 않고 새 기록만 추가한다.
- 검증: Phase 12 이후에 Phase 13 이상이 이어지고 모든 신규 task가 `- [ ]` 상태다.
---
### Phase 14: Logic-only 테스트 재작성
- [x] **Task 14.1: 기존 UI layout/visual 테스트 제거 대상 분류**
- 확인: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapterLayoutTest.kt`
- 확인: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt`
- 구현 내용:
- view size, margin, padding, constraints, `TextView` dimensions, inflated layout params, visibility attributes 검증을 제거 대상으로 분류한다.
- placement, text-style model, mapper, delta presentation, item behavior 검증은 logic contract 테스트로 유지하거나 이동한다.
- 검증: 후속 테스트 작업에서 삭제할 UI layout/visual assertion과 유지할 logic assertion이 구분되어 있다.
- [x] **Task 14.2: logic contract 테스트로 재작성**
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItemTest.kt`
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLayoutCalculatorTest.kt`
- 수정 또는 생성: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingPlacementTest.kt`
- 수정 또는 생성: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTextStyleTest.kt`
- 구현 내용:
- ranks `1~10`, ranks `11~20` 구분을 순수 로직으로 검증한다.
- 1 item row, 2 item row, 3 item row, lower-row text style model을 검증한다.
- `CreatorRankingDeltaPresentation`의 기존 `NEW`, 상승, 하락, 유지 의미를 유지한다.
- `HomeCreatorRankingMappers`의 기존 rank change 및 `showRankChange` 의미를 유지한다.
- 제외:
- 실제 view 크기, margin, padding, constraints, inflated layout params, visibility attributes assertion 추가 금지.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`
- 기대 결과: logic contract 테스트만 남고 UI layout/visual test는 없다.
---
### Phase 15: Placement와 text-style 로직 작성
- [x] **Task 15.1: rank placement model 작성**
- 수정 또는 생성: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingPlacement.kt`
- 구현 내용:
- adapter position과 rank를 기준으로 1 item row, 2 item row, 3 item row, lower row를 결정한다.
- ranks `1~10`은 상위 view type 안에서 row basis를 결정한다.
- ranks `11~20`은 lower-row basis를 사용한다.
- `rank > 20`은 mapper/API 동작 변경 없이 현재 lower-row 처리와 호환되게 둔다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingPlacementTest"`
- 기대 결과: placement logic contract가 통과한다.
- [x] **Task 15.2: text-style model 작성**
- 수정 또는 생성: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTextStyle.kt`
- 구현 내용:
- 1 item row rank/name/delta `96sp/32sp/16sp`
- 2 item row rank/name/delta `54sp/22sp/16sp`
- 3 item row rank/name/delta `36sp/14sp/14sp`
- ranks `11~20` lower-row rank/name `40sp/18sp`
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingTextStyleTest"`
- 기대 결과: text-style logic contract가 통과한다.
---
### Phase 16: Adapter two-view rewrite
- [x] **Task 16.1: adapter view type을 두 개로 축소**
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapter.kt`
- 구현 내용:
- view type은 ranks `1~10`, ranks `11~20` 두 개만 둔다.
- ranks `1~10` view holder는 placement model로 1 item row, 2 item row, 3 item row 표현을 선택한다.
- ranks `11~20` view holder는 lower-row 표현을 사용한다.
- `rank > 20`은 별도 mapper/API 변경 없이 현재 lower-row 처리와 호환되게 둔다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`
- 기대 결과: adapter view type logic 테스트가 통과한다.
- [x] **Task 16.2: adapter item behavior 유지**
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapter.kt`
- 구현 내용:
- `CreatorRankingItem.isTouchable`와 click listener 동작을 유지한다.
- `showRankChange=false` 의미는 delta presentation 적용 경로에서 유지한다.
- `creatorId=0` item 클릭 불가 계약을 유지한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingItemTest"`
- 기대 결과: 기존 item behavior가 회귀하지 않는다.
---
### Phase 17: ConstraintLayout view rewrite
- [x] **Task 17.1: ranks 1~10 view를 ConstraintLayout으로 작성**
- 수정 또는 생성: `app/src/main/res/layout/view_creator_ranking_top_card.xml`
- 수정 또는 생성: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardView.kt`
- 구현 내용:
- root는 `ConstraintLayout`을 사용한다.
- `FrameLayout`은 사용하지 않는다.
- Figma `24:5659`, `24:5660`, `24:5668` 기준으로 1 item row, 2 item row, 3 item row 표현을 처리한다.
- 고정 width/height를 layout sizing 목적으로 새로 도입하지 않는다.
- `wrap_content`, `match_parent`, margins, padding, constraints, match-constraint, 측정된 available width, aspect handling을 필요에 따라 사용한다.
- 검증 명령: `./gradlew :app:mergeDebugResources`
- 기대 결과: 신규 XML resource가 빌드된다.
- [x] **Task 17.2: ranks 11~20 view를 ConstraintLayout으로 작성**
- 수정 또는 생성: `app/src/main/res/layout/view_creator_ranking_lower_row.xml`
- 수정 또는 생성: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLowerRowView.kt`
- 구현 내용:
- root는 `ConstraintLayout`을 사용한다.
- `FrameLayout`은 사용하지 않는다.
- Figma `24:5670` lower-row basis를 사용한다.
- rank `40sp`, name `18sp`를 적용한다.
- rank change와 `NEW` 표시 의미는 `CreatorRankingDeltaPresentation` 경로를 유지한다.
- 검증 명령: `./gradlew :app:mergeDebugResources`
- 기대 결과: lower-row XML resource가 빌드된다.
---
### Phase 18: Obsolete FrameLayout cleanup
- [x] **Task 18.1: obsolete FrameLayout 기반 view 제거**
- 수정 또는 삭제: 기존 `creatorranking` legacy card view XML/Kotlin 중 더 이상 참조되지 않는 파일
- 구현 내용:
- 새 adapter가 참조하지 않는 `FrameLayout` 기반 card view 파일을 제거한다.
- 참조가 남은 legacy 파일은 삭제하지 않고 호출 경로를 먼저 끊는다.
- 리소스 id, binding import, unused import 중 이번 변경으로 생긴 잔여만 정리한다.
- 검증 명령: `./gradlew :app:compileDebugKotlin`
- 기대 결과: obsolete view 제거 후 컴파일이 통과한다.
- [x] **Task 18.2: FrameLayout 신규 사용 금지 확인**
- 확인: `app/src/main/res/layout/`
- 확인: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/`
- 구현 내용:
- 후속 rewrite로 추가한 creator ranking view에 `FrameLayout`이 없는지 확인한다.
- production code 또는 tests에 불필요한 obsolete view 참조가 남지 않았는지 확인한다.
- 검증: 검색 결과에서 신규 creator ranking view의 `FrameLayout` 사용이 없다.
---
### Phase 19: Home integration cleanup
- [x] **Task 19.1: 홈 랭킹 연결부 정리**
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt`
- 구현 내용:
- 새 adapter와 view holder 생성 경로를 연결한다.
- 기존 `HomeCreatorRankingMappers` 의미를 바꾸지 않는다.
- 기존 tab 전환, 최초 로드, empty/error 처리, profile 이동 guard를 유지한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`
- 기대 결과: 홈 랭킹 mapper와 item behavior가 회귀하지 않는다.
- 검증 기록: `fragment_v2_main_home.xml``tools:listitem`을 새 `view_creator_ranking_lower_row`로 갱신했고, `HomeMainFragment`는 기존 `CreatorRankingAdapter`, tab 전환, 최초 로드, empty/error 처리, profile 이동 guard 경로를 유지했다. 랭킹 관련 layout 속성 assertion은 logic/source contract 중심으로 축소했다. `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"``BUILD SUCCESSFUL`로 통과했다.
- [x] **Task 19.2: home integration 문서 기록 누적**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 수정: `docs/20260608_크리에이터_랭킹_페이지/prd.md`
- 구현 내용:
- home integration cleanup에서 유지한 mapper/API, profile 이동, rank change 의미를 Verification Log에 누적한다.
- 기존 Verification Log는 삭제하거나 덮어쓰지 않는다.
- 검증: 문서 기록과 실제 구현 범위가 일치한다.
- 검증 기록: home integration cleanup에서 mapper/API, profile 이동, rank change 의미를 변경하지 않았고, 검증 결과를 Verification Log에 누적했다.
---
### Phase 20: Final validation
- [x] **Task 20.1: logic-only targeted test 실행**
- 실행:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`
- 기대 결과: logic contract 테스트가 `BUILD SUCCESSFUL`로 통과한다.
- 제외: UI layout/visual 속성 검증 테스트를 새로 작성하거나 실행 대상으로 추가하지 않는다.
- 검증 기록: `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`, `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeCreatorRankingMapperTest"`가 모두 `BUILD SUCCESSFUL`로 통과했다.
- [x] **Task 20.2: build와 style 검증**
- 실행:
- `./gradlew :app:mergeDebugResources`
- `./gradlew :app:compileDebugKotlin`
- `./gradlew :app:ktlintCheck`
- 기대 결과: 모두 `BUILD SUCCESSFUL`.
- 참고: 기존 전역 경고나 기존 전역 위반이 있으면 신규 변경과 분리해 Verification Log에 기록한다.
- 검증 기록: `./gradlew :app:mergeDebugResources`, `./gradlew --no-daemon :app:compileDebugKotlin`, `./gradlew --no-daemon :app:ktlintCheck`, `git diff --check`가 모두 통과했다. 병렬 Gradle 실행 중 Kotlin incremental cache 삭제/backup 오류와 timeout이 발생했으나 `./gradlew --stop``--no-daemon` 순차 재실행으로 통과했다. Gradle deprecated feature warning과 기존 Kotlin warning은 신규 변경과 무관한 기존 경고로 보고 수정하지 않았다.
- [x] **Task 20.3: 최종 문서 검증 기록 누적**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 수정: `docs/20260608_크리에이터_랭킹_페이지/prd.md`
- 구현 내용:
- 실행한 검증 명령과 결과를 Verification Log에 누적한다.
- `ConstraintLayout` 사용, `FrameLayout` 미사용, two-view adapter, logic-only test 정책 준수 여부를 기록한다.
- 기존 Verification Log는 삭제하거나 덮어쓰지 않는다.
- 검증: Phase 13~20의 체크 상태, 실제 구현 상태, 최종 검증 기록이 일치한다.
- 검증 기록: Phase 13~20 체크 상태와 실제 구현/검증 상태를 맞추고, `ConstraintLayout` 사용, `FrameLayout` 미사용, two-view adapter, logic-only test 정책 준수 결과를 Verification Log에 누적했다.
---
### Phase 21: 상위 랭킹 카드 margin 보정
- [x] **Task 21.1: margin-only 후속 범위 문서화**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 구현 내용:
- ranks `1~10` top-card의 닉네임 margin과 순위 left margin만 보정한다.
- rank `1` 닉네임 margin은 좌/우 `20dp`, 아래 `24dp`로 적용한다.
- ranks `2~10` 닉네임 margin은 좌/우/아래 `10dp`로 적용한다.
- 순위 left margin은 rank `1` `10dp`, ranks `2~7` `8dp`, ranks `8~10` `6dp`로 적용한다.
- 이번 단계에서는 순위 텍스트와 delta 사이 간격, ranks `11+` lower-row delta 동작은 변경하지 않는다.
- 검증: 후속 구현 범위와 제외 범위가 문서에 명확히 기록되어 있다.
- [x] **Task 21.2: top-card margin policy logic 추가**
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMargin.kt`
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMarginTest.kt`
- 구현 내용:
- rank 구간별 닉네임 좌/우/아래 margin과 rank group start margin을 순수 로직으로 분리한다.
- ranks `1`, `2~7`, `8~10` 값을 검증한다.
- ranks `11+`와 delta spacing은 테스트 대상에 포함하지 않는다.
- 검증: margin policy 테스트가 요청 수치를 검증한다.
- [x] **Task 21.3: top-card view에 margin policy 적용**
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardView.kt`
- 구현 내용:
- `CreatorRankingTopCardView.bind()`에서 rank별 margin policy를 `ll_creator_ranking_rank_group``tv_creator_ranking_name`에 적용한다.
- `CreatorRankingDeltaPresentation`, delta icon margin, 순위 텍스트와 delta 사이 간격, `CreatorRankingLowerRowView`는 변경하지 않는다.
- 검증: top-card margin policy가 bind 시 적용되고 lower-row/delta spacing 변경이 없다.
- [x] **Task 21.4: 후속 검증과 문서 기록 누적**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 실행:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`
- `./gradlew :app:mergeDebugResources`
- `./gradlew :app:compileDebugKotlin`
- `./gradlew :app:ktlintCheck`
- `git diff --check`
- 기대 결과: 모두 `BUILD SUCCESSFUL` 또는 출력 없음.
- 검증 기록: 실행 결과와 lower-row/delta spacing 제외 여부를 Verification Log에 누적한다.
---
### Phase 22: 하위 랭킹 delta 표시 보정
- [x] **Task 22.1: lower-row delta 표시 보정 범위 문서화**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 구현 내용:
- ranks `1~10`과 ranks `11~20`의 delta 표시 정책은 동일하게 `showRankChange`를 따른다.
- ranks `11~20`에서만 delta가 보이지 않는 문제는 mapper/API 의미 변경이 아니라 lower-row layout/measurement clipping 가능성으로 처리한다.
- top-card rank/delta/name 및 lower-row rank/delta/name `TextView`는 이미 `includeFontPadding=false`이므로, 이번 단계에서는 top-card 순위 텍스트와 delta 사이 간격을 변경하지 않는다.
- 3열 grid의 delta amount text size는 `14sp`이고, `NEW` 표시는 text가 아니라 `36dp x 23dp` image presentation임을 확인한다.
- 검증: 구현 범위가 lower-row delta visibility 보정으로 제한되고 API/mapper/top-card spacing 변경이 제외되어 있다.
- [x] **Task 22.2: lower-row measurement clipping 최소 보정**
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLowerRowView.kt`
- 구현 내용:
- lower-row의 기존 Figma 비율 height를 기본값으로 유지하되, rank group content가 잘리지 않도록 필요한 최소 height를 함께 고려한다.
- `showRankChange=false` 숨김 의미와 `CreatorRankingDeltaPresentation` 적용 의미는 변경하지 않는다.
- `HomeCreatorRankingMappers`, API DTO, `CreatorRankingTopCardView`, top-card XML은 변경하지 않는다.
- 검증: ranks `11~20` lower-row에서 delta group이 clipping되지 않도록 측정된다.
- [x] **Task 22.3: 후속 검증과 문서 기록 누적**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 실행:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`
- `./gradlew :app:mergeDebugResources`
- `./gradlew :app:compileDebugKotlin`
- `./gradlew :app:ktlintCheck`
- `git diff --check`
- 기대 결과: 모두 `BUILD SUCCESSFUL` 또는 출력 없음.
- 검증 기록: lower-row delta visibility 보정, top-card spacing 미변경, 3열 delta text/NEW image 차이를 Verification Log에 누적한다.
---
### Phase 23: 상위 랭킹 순위 텍스트 margin 적용 대상 보정
- [x] **Task 23.1: rank text margin 대상 보정 범위 문서화**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 구현 내용:
- Phase 21의 순위 left margin은 rank/delta를 감싸는 `ll_creator_ranking_rank_group`이 아니라 `tv_creator_ranking_rank`에만 적용한다.
- rank delta 위치와 rank group 자체 위치는 변경하지 않는다.
- 닉네임 margin 정책은 기존 Phase 21 값을 유지한다.
- 검증: 보정 범위가 rank text start margin 적용 대상으로 제한되어 있다.
- [x] **Task 23.2: top-card margin policy와 적용 대상 보정**
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMargin.kt`
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardView.kt`
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMarginTest.kt`
- 구현 내용:
- margin policy 명칭을 rank group start가 아닌 rank text start 의미로 정리한다.
- `CreatorRankingTopCardView.bind()`에서 `tv_creator_ranking_rank``MarginLayoutParams.marginStart`만 rank별 값으로 적용한다.
- `ll_creator_ranking_rank_group`, `ll_creator_ranking_delta`, `CreatorRankingDeltaPresentation`은 변경하지 않는다.
- 검증: rank별 값은 기존 Phase 21 수치를 유지하고 적용 대상만 rank text로 제한된다.
- [x] **Task 23.3: 후속 검증과 문서 기록 누적**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 실행:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`
- `./gradlew :app:mergeDebugResources`
- `./gradlew :app:compileDebugKotlin`
- `./gradlew :app:ktlintCheck`
- `git diff --check`
- 기대 결과: 모두 `BUILD SUCCESSFUL` 또는 출력 없음.
- 검증 기록: rank text margin 적용 대상 보정 결과를 Verification Log에 누적한다.
---
### Phase 24: rankTextStartDp 변경 롤백
- [x] **Task 24.1: rankTextStartDp 롤백 범위 문서화**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 구현 내용:
- Phase 23의 `rankTextStartDp` 변경을 롤백한다.
- margin policy 명칭과 적용 대상을 Phase 21의 `rankGroupStartDp` 기준으로 되돌린다.
- 닉네임 margin, lower-row delta measurement, API/mapper 의미는 변경하지 않는다.
- 검증: 롤백 범위가 `rankTextStartDp` 변경에 한정되어 있다.
- [x] **Task 24.2: rankGroupStartDp 적용 복원**
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMargin.kt`
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardView.kt`
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardMarginTest.kt`
- 구현 내용:
- `rankTextStartDp``rankGroupStartDp`로 되돌린다.
- `CreatorRankingTopCardView.bind()`에서 `ll_creator_ranking_rank_group``MarginLayoutParams.marginStart`에 rank별 값을 다시 적용한다.
- Phase 21의 닉네임 margin 값은 유지한다.
- 검증: rank group margin application이 복원된다.
- [x] **Task 24.3: 롤백 검증과 문서 기록 누적**
- 수정: `docs/20260608_크리에이터_랭킹_페이지/plan-task.md`
- 실행:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`
- `./gradlew :app:mergeDebugResources`
- `./gradlew :app:compileDebugKotlin`
- `./gradlew :app:ktlintCheck`
- `git diff --check`
- 기대 결과: 모두 `BUILD SUCCESSFUL` 또는 출력 없음.
- 검증 기록: `rankTextStartDp` 변경 롤백 결과를 Verification Log에 누적한다.
## Verification Log
- 2026-06-30: Phase 24로 사용자 요청에 따라 Phase 23의 `rankTextStartDp` 변경을 롤백했다. `CreatorRankingTopCardMargin``rankGroupStartDp` 명칭으로 복원했고, `CreatorRankingTopCardView.bind()``ll_creator_ranking_rank_group``MarginLayoutParams.marginStart`에 rank별 값을 다시 적용한다. 닉네임 margin, lower-row delta measurement, API/mapper 의미는 변경하지 않았다. 검증으로 `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`, `./gradlew --no-daemon :app:mergeDebugResources`, `./gradlew --no-daemon :app:compileDebugKotlin`, `./gradlew --no-daemon :app:ktlintCheck`, `git diff --check`가 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다. Gradle deprecated warning과 `.editorconfig disabled_rules` deprecation warning은 기존 경고로 보고 수정하지 않았다.
- 2026-06-30: Phase 23으로 Phase 21의 순위 left margin 적용 대상을 `ll_creator_ranking_rank_group`에서 `tv_creator_ranking_rank`로 보정했다. `CreatorRankingTopCardMargin.rankGroupStartDp``rankTextStartDp`로 정리하고, `CreatorRankingTopCardView.bind()`에서는 rank text의 `MarginLayoutParams.marginStart`에만 rank별 값을 적용한다. rank delta 위치와 rank group 자체 위치, 닉네임 margin 정책, `CreatorRankingDeltaPresentation`은 변경하지 않았다. 검증으로 `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`, `./gradlew --no-daemon :app:mergeDebugResources`, `./gradlew --no-daemon :app:compileDebugKotlin`, `./gradlew --no-daemon :app:ktlintCheck`, `git diff --check`가 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다. Gradle deprecated warning과 `.editorconfig disabled_rules` deprecation warning은 기존 경고로 보고 수정하지 않았다.
- 2026-06-30: Phase 22로 ranks `11~20` lower-row delta가 ranks `1~10`과 동일하게 `showRankChange` 정책을 따르도록 유지하면서, delta가 보이지 않는 원인을 lower-row measurement clipping 가능성으로 보고 `CreatorRankingLowerRowView.onMeasure()`를 보정했다. 기존 Figma 비율 height를 기본값으로 유지하되 rank group measured height와 root vertical padding 합보다 작아지지 않도록 재측정한다. `HomeCreatorRankingMappers`, API DTO, `CreatorRankingDeltaPresentation`, `showRankChange` 의미, top-card rank/delta spacing은 변경하지 않았다. top-card/lower-row rank/delta/name은 이미 `includeFontPadding=false`라 추가 font padding 변경은 하지 않았고, 3열 grid delta amount text는 `14sp`, `NEW`는 text가 아니라 `36dp x 23dp` image presentation임을 확인했다. 검증으로 `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`, `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew --no-daemon :app:mergeDebugResources`, `./gradlew --no-daemon :app:compileDebugKotlin`, `./gradlew --no-daemon :app:ktlintCheck`, `git diff --check`가 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다. home test 실행 중 Kotlin incremental cache registration 오류가 있었으나 fallback 후 `BUILD SUCCESSFUL`로 종료했다. Gradle deprecated warning과 `.editorconfig disabled_rules` deprecation warning은 기존 경고로 보고 수정하지 않았다.
- 2026-06-30: Phase 21 margin-only 후속으로 `CreatorRankingTopCardMargin` 순수 로직과 `CreatorRankingTopCardMarginTest`를 추가해 rank `1`, `2~7`, `8~10`의 닉네임 좌/우/하단 margin 및 rank group start margin 값을 검증했다. `CreatorRankingTopCardView.bind()`에서는 기존 `ll_creator_ranking_rank_group`, `tv_creator_ranking_name``MarginLayoutParams`에만 정책을 적용했다. `CreatorRankingDeltaPresentation`, delta icon margin, `showRankChange`, `CreatorRankingLowerRowView`, lower-row XML, content ranking 파일은 변경하지 않았다. 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingTopCardMarginTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`, `./gradlew :app:ktlintCheck`가 모두 `BUILD SUCCESSFUL`로 통과했고, `GIT_MASTER=1 git diff --check`는 출력 없이 통과했다. `compileDebugKotlin``mergeDebugResources`는 위 테스트 실행 중 성공 또는 up-to-date로 확인했다. Gradle deprecated feature warning과 `.editorconfig disabled_rules` deprecation warning은 기존 경고로 보고 수정하지 않았다.
- 2026-06-29: Phase 19~20 최종 검증으로 `fragment_v2_main_home.xml`의 ranking list preview를 새 `view_creator_ranking_lower_row`로 갱신하고, `HomeMainFragment`의 기존 adapter 연결, tab 전환, 최초 로드, empty/error 처리, profile 이동 guard, mapper/API 의미가 유지됨을 확인했다. `HomeMainFragmentLayoutTest`의 랭킹 layout 속성 assertion은 logic/source contract 중심으로 축소했다.
- 2026-06-29: 최종 검증으로 `./gradlew :app:mergeDebugResources`, `./gradlew --no-daemon :app:compileDebugKotlin`, `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`, `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeCreatorRankingMapperTest"`, `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`, `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew --no-daemon :app:ktlintCheck`, `git diff --check`가 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다. `rg`로 신규 creator ranking XML/Kotlin에 `FrameLayout` 사용이 없고, old Large/Compact/Horizontal/LayoutCalculator/CardVariant/CardSize/setCardSize 참조가 creator ranking 범위에 남지 않았음을 확인했다. 병렬 Gradle 실행 중 Kotlin incremental cache 삭제/backup 오류와 timeout이 있었으나 `./gradlew --stop``--no-daemon` 순차 재실행으로 통과했다.
- 2026-06-29: Phase 16~18로 `CreatorRankingAdapter`의 내부 view type을 ranks `1~10` top-card와 ranks `11+` lower-row 두 개로 축소하고, 새 `CreatorRankingTopCardView`/`CreatorRankingLowerRowView``view_creator_ranking_top_card.xml`/`view_creator_ranking_lower_row.xml``ConstraintLayout` root로 작성했다. 기존 `CreatorRankingLargeCardView`, `CreatorRankingCompactCardView`, `CreatorRankingHorizontalCardView`, `CreatorRankingLayoutCalculator`, `CreatorRankingCardVariant`와 old layout 세 개는 참조 제거 후 삭제했고, `fragment_v2_main_home.xml``tools:listitem`은 lower-row layout으로 갱신했다. mapper/API 동작과 content ranking 파일은 변경하지 않았고, rank gradient, `CreatorRankingItem.displayName(...)`, `isTouchable`, adapter `loadUrl` + `CreatorRankingBlur.transformations`, `CreatorRankingDeltaPresentation.from(...)`, `showRankChange=false` delta hide 경로, `CreatorRankingTextStyle.fromRank(...)` 텍스트 크기 적용을 유지했다. 검증으로 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeCreatorRankingMapperTest"`, `GIT_MASTER=1 git diff --check`가 모두 성공했다. `rg`로 creatorranking production 범위의 obsolete Large/Compact/Horizontal/LayoutCalculator/CardVariant/setCardSize/CardSize 참조와 신규 creator ranking XML/Kotlin의 `FrameLayout` 사용이 없음을 확인했다. Gradle deprecated feature warning 및 기존 전역 Kotlin warning은 기존 경고로 보고 수정하지 않았다.
- 2026-06-29: Phase 14/15로 `CreatorRankingAdapterLayoutTest`의 Robolectric inflation 및 view size/margin/padding/constraint/TextView dimension/visibility assertion을 제거하고 span/decoration 순수 로직 계약만 남겼다. `CreatorRankingPlacement``CreatorRankingViewType.TopTen/Lower``itemsPerRow` 계약으로 갱신했고, `CreatorRankingTextStyle``CreatorRankingTextStyleTest`를 추가했다. 고정 card dimension을 요구하던 `CreatorRankingLayoutCalculatorTest`는 삭제했다. 검증으로 `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeCreatorRankingMapperTest"`를 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다. 최초 위젯 테스트 실행에서 Kotlin 증분 빌드 참조 오류가 한 번 발생했으나 `compileDebugKotlin` 재실행 후 동일 테스트가 성공했다. Gradle deprecated feature warning은 기존 경고로 보고 수정하지 않았다.
- 2026-06-29: Phase 11 요구사항으로 rank-area 계약을 갱신했다. PRD와 계획 문서에 `tv_creator_ranking_rank` XML/Kotlin width/height `wrap_content` 유지, Kotlin의 텍스트 크기 및 spacing/margin 조정 범위, 순위 숫자와 rank-num의 rank area 중앙 정렬 기준을 먼저 반영했다.
- 2026-06-29: Phase 11.2 RED로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`를 실행해 4건 실패를 확인했다. Large/Compact는 rank group이 없어 실패했고, Horizontal은 rank `TextView`가 Kotlin에서 고정 layoutParams를 받아 `wrap_content` 계약을 위반했다.
- 2026-06-29: Phase 11.3으로 `view_creator_ranking_large_card.xml`, `view_creator_ranking_compact_card.xml``ll_creator_ranking_rank_group`을 추가해 rank와 delta를 함께 세로 중앙 정렬했다. `CreatorRankingLargeCardView`, `CreatorRankingCompactCardView`, `CreatorRankingHorizontalCardView`에서는 rank `TextView` 고정 width/height 설정을 제거하고 rank group/delta 배치만 조정했다.
- 2026-06-29: Phase 11.4 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`을 순차 실행했고 모두 `BUILD SUCCESSFUL`로 통과했다. `rg`로 Kotlin 내 rank `TextView` 고정 sizing 잔여 없음과 세 XML의 `tv_creator_ranking_rank` `wrap_content` 유지를 확인했다. Gradle deprecated feature warning은 기존 빌드 경고로 보고 수정하지 않았다.
- 2026-06-29: 추가 확인 중 Horizontal rank-num 간격이 XML `layout_marginTop`에 남아 있어 사용자 요구사항의 Kotlin spacing 조정 범위와 맞지 않음을 확인했다. `view_creator_ranking_horizontal_card.xml`의 margin을 제거하고 `CreatorRankingHorizontalCardView`에서 scale 기반 `topMargin`을 설정하도록 보정했다. 이후 `CreatorRankingAdapterLayoutTest`, `mergeDebugResources`, `compileDebugKotlin`이 모두 `BUILD SUCCESSFUL`로 통과했고, `rg`로 XML 고정 margin 및 Kotlin rank `TextView` 고정 sizing 잔여 없음도 확인했다. 최초 병렬 Gradle 중 Kotlin incremental cache 충돌이 있었으나 fallback/재실행으로 성공했으며, 기존 deprecation warning은 수정하지 않았다.
- 2026-06-29: Phase 12 요구사항으로 8~10위 Compact small의 rank/rank-num 간격 과다, rank parent 고정 폭 의심, `10` 표시 문제, 닉네임 축소 요구를 분석했다. 직접 확인과 병렬 explore 결과 모두 `CreatorRankingCompactCardView.positionSmall()`에서 rank group 폭이 `52 * scale`로 고정되는 점을 핵심 원인으로 확인했고, rank `TextView` 자체는 XML/Kotlin 모두 `wrap_content`임을 확인했다.
- 2026-06-29: Phase 12 RED로 `CreatorRankingAdapterLayoutTest`를 갱신해 실제 3열 폭에 가까운 `119x119` 조건에서 `rank=10`, rank group `WRAP_CONTENT`, 닉네임 `13sp`, rank-num `topMargin=-4`를 검증하도록 했다. production 수정 전에는 rank group fixed width assertion이 실패했고, `CreatorRankingCompactCardView.positionSmall()` 보정 후 동일 테스트가 `BUILD SUCCESSFUL`로 통과했다.
- 2026-06-29: Phase 12 최종 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`가 통과했다. 병렬 Gradle 실행 중 Kotlin incremental cache 충돌이 있었으나 `./gradlew --stop` 후 순차 재실행으로 통과했다.
- 2026-06-08: `superpowers:writing-plans` 지침, PRD `docs/20260608_크리에이터_랭킹_페이지/prd.md`, 기존 홈 추천 계획 문서, `fragment_v2_main_home.xml`, `HomeMainFragment`, `AppDI`, 기존 홈 추천 API/ViewModel, `creatorranking` 위젯 구조와 테스트 위치를 확인했다.
- 2026-06-08: 이번 단계는 계획 문서 작성만 수행했으며 구현/빌드/테스트는 실행하지 않았다.
- 2026-06-08: Phase 1 범위로 PRD와 기존 `HomeMainFragment`, `fragment_v2_main_home.xml`, `AppDI`, 홈 추천 API/Repository/ViewModel, `creatorranking` 위젯/테스트 구조를 재확인했다. Capsule Tab bar, 팔로잉 탭 content, analytics/logging, ViewPager2/swipe 전환은 Phase 1-3 구현 범위에서 제외했다.
@@ -399,3 +904,10 @@
- 2026-06-09: Phase 8.3 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:ktlintCheck`를 실행했다. 최초 ktlint에서 테스트 파일 불필요한 빈 줄 2건이 실패해 정리했고, 이후 `CreatorRankingAdapterLayoutTest``ktlintCheck``BUILD SUCCESSFUL`로 통과했다. Gradle deprecated feature warning은 기존 빌드 경고로 보고 수정하지 않았다.
- 2026-06-25: Phase 9.1로 `CreatorRankingAdapterLayoutTest`에 순위 `TextView``includeFontPadding=false` 유지와 variant별 하단 padding(1위 10px, 2~7위 6px, 8~10위 5px, 11위 이후 4px) 검증을 추가했다. Phase 9.2로 기존 Figma 좌표와 rank-num top/left는 유지하고 순위 `TextView` 내부 하단 padding만 scale 기반으로 적용했다.
- 2026-06-25: Phase 9 검증 중 최초 병렬 Gradle 실행에서 Kotlin incremental cache 충돌과 timeout이 발생해 `./gradlew --stop`, `./gradlew clean` 후 순차 재실행했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`은 모두 `BUILD SUCCESSFUL`로 통과했다. `./gradlew :app:ktlintCheck`는 변경 파일이 아닌 기존 전역 위반(`Agora.kt`, `audio_content` package-name, 기존 `CreatorRankingAdapter.kt` 긴 줄 등)으로 실패했으며, `git diff --check`는 출력 없이 통과했다.
- 2026-06-29: Phase 10 요구사항으로 Figma `24:5668`, `24:5661`, `24:5670` design context와 screenshot을 확인했다. 3열 Compact는 순위 `36sp`, 닉네임 `14sp`, rank-num 숫자 `14sp`, 2열 Compact는 순위 `54sp`, 닉네임 `22sp`, rank-num 숫자 `16sp`, 11위 이후 Horizontal은 rank group 내부 세로 배치 유지, 랭킹 item 간격은 `8dp`로 정리했다.
- 2026-06-29: Phase 10.1 RED로 `CreatorRankingAdapterLayoutTest`에 Compact 2열/3열 텍스트 크기와 Horizontal rank group 세로 배치 검증을 추가하고 실행했다. production 수정 전 3열 Compact 순위 텍스트 크기 assertion 실패를 확인했다. `CreatorRankingLayoutCalculatorTest``8dp` 입력 기준 계산 테스트라 기존 코드에서도 통과했다.
- 2026-06-29: Phase 10.2로 `CreatorRankingCompactCardView`의 3열 Compact 순위 텍스트를 `36sp`, rank-num 숫자를 `14sp`로 보정하고 2열 rank-num 숫자 `16sp`를 명시 유지했다. `CreatorRankingAdapter`의 랭킹 item 폭 계산 기준을 `8dp`로 변경하고, 실제 `GridLayoutManager` 배치에서 인접 item offset 합이 `8dp`가 되도록 `CreatorRankingItemDecoration`을 추가했다. `HomeMainFragment`에는 decoration을 중복 없이 적용했다.
- 2026-06-29: Phase 10 리뷰 게이트에서 카드 폭 계산만 줄이면 실제 `GridLayoutManager` item 사이 간격이 `8dp`로 보장되지 않는다는 차단 이슈가 나왔다. 이에 `CreatorRankingAdapterLayoutTest`에 전용 decoration offset 계약을 추가하고, `HomeMainFragmentLayoutTest.home ranking fragment wires adapter and grid layout manager`에 decoration 연결 검증을 추가했다. production 수정 전에는 `CreatorRankingItemDecoration` 미구현 컴파일 실패를 확인했고, 구현 후 두 테스트가 `BUILD SUCCESSFUL`로 통과했다.
- 2026-06-29: Phase 10 검증 중 최초 병렬 Gradle 실행에서 Kotlin incremental cache 충돌과 timeout이 발생해 `./gradlew --stop`, `./gradlew clean` 후 순차 재실행했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLayoutCalculatorTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`가 모두 `BUILD SUCCESSFUL`로 통과했다. `git diff --check`도 출력 없이 통과했다.
- 2026-06-29: Phase 13~20 후속 rewrite 계획을 추가했다. 순서는 docs, logic-only test rewrite, placement/text-style logic, adapter two-view rewrite, ConstraintLayout view rewrite, obsolete FrameLayout cleanup, home integration cleanup, final validation이며, 모든 신규 task는 구현 전 상태로 `- [ ]` 체크박스를 유지했다.
- 2026-06-29: 후속 rewrite 전제로 `ConstraintLayout` 사용, `FrameLayout` 미사용, adapter view type 두 개(ranks `1~10`, ranks `11~20`), Figma `24:5659`, `24:5660`, `24:5668`, `24:5670`, 텍스트 크기 정책, mapper/API 변경 제외, logic-only 테스트 정책을 계획 문서에 반영했다.

View File

@@ -29,7 +29,7 @@
- 크리에이터 랭킹 위젯의 순위별 카드 variant 정책 자체를 새로 설계하지 않는다.
- Figma에 없는 skeleton, shimmer, pagination, pull-to-refresh, 추가 배너, 광고 영역은 구현하지 않는다.
- 서버 API 스키마를 클라이언트에서 임의로 변경하지 않는다.
- Compose 전환, ViewPager2 기반 tab swipe 전환, tab별 신규 Fragment 대량 분리는 이번 범위에 포함하지 않는다.
- ViewPager2 기반 tab swipe 전환, tab별 신규 Fragment 대량 분리는 이번 범위에 포함하지 않는다.
---
@@ -142,6 +142,43 @@
- `creatorId=0`인 차단 관계 item은 상세 이동을 막고, 기존 차단 상태 위젯 정책에 따라 이미지 블러와 이름 비노출/대체문구를 적용한다.
- 동일 rank가 중복되면 서버 데이터 오류로 보고 클라이언트는 받은 순서 또는 `rank` 정렬 결과를 그대로 표시한다. 중복 보정 UI는 추가하지 않는다.
### Creator Ranking View Rewrite Follow-up
크리에이터 랭킹 view는 후속 구현에서 기존 view를 부분 보정하지 않고 새로 작성한다. 이 문서 갱신은 rewrite 요구사항과 구현 계획만 정리하며, mapper/API 동작 변경이나 production code 변경은 포함하지 않는다.
#### Figma References
- 1 per row: Figma `24:5659`
- 2 per row: Figma `24:5660`
- 3 per row: Figma `24:5668`
- 11~20: Figma `24:5670`
#### Rewrite Requirements
- 신규 크리에이터 랭킹 view는 `ConstraintLayout`을 사용해 작성한다.
- 신규 크리에이터 랭킹 view에는 `FrameLayout`을 사용하지 않는다.
- adapter view type은 두 개만 둔다.
- ranks `1~10`
- ranks `11~20`
- `rank > 20` 값은 이번 문서 갱신에서 mapper/API 동작을 바꾸지 않는다. 별도 요구가 있기 전까지 현재 구현의 lower-row 처리 방식은 유지할 수 있다.
- rank change와 `NEW` 표시 로직은 `CreatorRankingDeltaPresentation``HomeCreatorRankingMappers`의 기존 의미를 유지한다.
- `isNew`, `rankChange`, `showRankChange`, `RankingChangeType` 매핑 의미는 변경하지 않는다.
#### Text Size Policy
| Rank group | Row basis | Rank | Name | Delta |
| --- | --- | --- | --- | --- |
| `1~10` | 1 item row | `96sp` | `32sp` | `16sp` |
| `1~10` | 2 item row | `54sp` | `22sp` | `16sp` |
| `1~10` | 3 item row | `36sp` | `14sp` | `14sp` |
| `11~20` | lower-row Figma basis | `40sp` | `18sp` | 기존 lower-row 표시 정책 유지 |
#### View Size Policy
- XML/Kotlin에서 layout sizing 목적으로 고정 width/height를 새로 도입하지 않는다.
- layout sizing은 `wrap_content`, `match_parent`, margins, padding, constraints, match-constraint, 측정된 available width, aspect handling을 필요에 따라 조합한다.
- 텍스트 크기, rank/name/delta 배치 모델, image aspect 계산처럼 화면 표현을 결정하는 값은 순수 로직으로 분리해 테스트할 수 있게 한다.
#### Testing Policy
- 테스트는 placement, text-style model, mapper, delta presentation, item behavior 같은 logic contract에만 작성한다.
- view size, margin, padding, constraints, `TextView` dimensions, inflated layout params, visibility attributes에 대한 UI layout/visual test는 작성하지 않는다.
- `CreatorRankingDeltaPresentation``HomeCreatorRankingMappers`의 기존 rank change 및 `NEW` 의미가 유지되는지는 로직 테스트로 확인한다.
---
## 8. UX / UI Expectations
@@ -149,9 +186,11 @@
- TitleBar와 Text Tab bar는 Figma `24:5654` 및 기존 홈 추천 구현과 동일한 위치/스타일을 유지한다.
- Capsule Tab bar를 제거한 상태에서도 Text Tab bar 아래 여백이 과도하게 남지 않아야 한다.
- 랭킹 목록 좌우 margin과 item gap은 기존 `CreatorRankingAdapter`와 Figma 목록 폭을 기준으로 맞춘다.
- 순위 숫자는 Figma의 시각적 margin을 임의로 추가하지 않고, variant별 고정 텍스트 박스 크기 안에서 중앙 정렬해 표시한다.
- 1위, 2~7위, 8~10위, 11위 이후 순위 영역의 고정 박스 크기와 위치는 Figma `24:5658`의 최신 기준값을 따른다.
- `rank-num`Figma 기준 위치를 따른다. 1위는 `top=116`, 2~7위는 `top=70`, 8~10위는 `top=50`, 11위 이후는 rank group 내부 배치를 유지한다.
- 순위 숫자 `TextView`의 XML 및 Kotlin layout width/height는 `wrap_content`를 유지하고, Kotlin은 variant별 텍스트 크기와 rank area의 spacing/margin만 조정한다.
- 1위, 2~7위, 8~10위, 11위 이후 순위 영역은 rank 숫자와 rank-num을 하나의 세로 rank group으로 묶어 해당 영역 안에서 가운데 정렬한다.
- `rank-num`순위 숫자 아래에서 같은 rank area의 중앙 정렬 흐름을 따르며, 11위 이후 Horizontal은 기존 `ll_creator_ranking_rank_group`의 vertical/center 배치를 유지한다.
- 8~10위 Compact small은 두 자리 순위(`10`)가 잘리지 않도록 rank group도 고정 폭이 아닌 `wrap_content`로 유지하고, rank와 rank-num 사이 간격을 Kotlin margin으로 더 좁힌다.
- 8~10위 Compact small 닉네임은 3열 카드 폭에서 답답하지 않도록 기존 14sp보다 작게 표시한다.
- `랭킹` 탭 선택 상태가 흰색 텍스트로 명확히 드러나야 한다.
- 1위~20위까지 응답이 내려오면 Figma 예시처럼 20위까지 자연스럽게 스크롤로 확인할 수 있어야 한다.
- 긴 닉네임은 기존 위젯의 ellipsize/line 제한 정책을 따른다.
@@ -174,8 +213,10 @@
- `랭킹` 탭 선택 시 `GET /api/v2/home/rankings/creators` 응답 item이 `CreatorRankingAdapter`에 전달된다.
- Figma `24:5654`의 Capsule Tab bar는 화면에 존재하지 않는다.
- 1위, 2~7위, 8~10위, 11위 이후 variant가 기존 위젯 정책과 일치한다.
- 순위 숫자는 Figma `24:5658`의 고정 텍스트 박스 내부에서 중앙 정렬되어 동일한 시각적 좌측 여백으로 보인다.
- 1위 순위 박스는 `86x116`, 2~7위는 `56x70`, 8~10위는 `52x50`, 11위 이후 순위 텍스트는 `48x52` 기준으로 표시된다.
- 순위 숫자 `TextView`는 모든 variant에서 `wrap_content` width/height, `Gravity.CENTER`, `includeFontPadding=false`를 유지한다.
- 1위, 2~7위, 8~10위, 11위 이후 rank group은 순위 숫자와 rank-num을 같은 rank area 중앙에 세로 정렬한다.
- Kotlin은 Compact 2열 순위 `54sp`/닉네임 `22sp`/rank-num `16sp`, Compact 3열 순위 `36sp`/닉네임 `13sp`/rank-num `14sp` 텍스트 크기 계약을 유지한다.
- Compact 3열 rank group은 `wrap_content` width를 유지하고, `rank=10`도 전체 숫자가 표시되어야 한다.
- `isNew=true` item은 New badge로 표시된다.
- `isNew=false && (rankChange=null || rankChange=0)` item은 유지 상태로 표시된다.
- `rankChange > 0` item은 상승, `rankChange < 0` item은 하락으로 표시된다.
@@ -185,6 +226,11 @@
- API 응답은 클라이언트에서 `rank` 기준 오름차순으로 한 번 더 정렬된다.
- 빈 응답, API 실패, 이미지 실패가 crash 없이 처리된다.
- 관련 mapper/unit test, `HomeMainFragment` layout test, `compileDebugKotlin`, `ktlintCheck`가 성공한다.
- 후속 rewrite에서는 신규 크리에이터 랭킹 view가 `ConstraintLayout` 기반이고 `FrameLayout`을 사용하지 않는다.
- 후속 rewrite에서는 adapter view type이 ranks `1~10`, ranks `11~20` 두 개뿐이다.
- 후속 rewrite에서는 ranks `1~10` 텍스트 크기가 1 item row `96sp/32sp/16sp`, 2 item row `54sp/22sp/16sp`, 3 item row `36sp/14sp/14sp` rank/name/delta 정책을 따른다.
- 후속 rewrite에서는 ranks `11~20`이 Figma `24:5670` lower-row 기준으로 rank `40sp`, name `18sp`를 사용한다.
- 후속 rewrite 테스트는 logic contract 중심이며, UI layout/visual 속성 검증 테스트는 작성하지 않는다.
---
@@ -195,6 +241,10 @@
## 12. References
- Figma: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=24-5654&m=dev
- Figma 1 per row: `24:5659`
- Figma 2 per row: `24:5660`
- Figma 3 per row: `24:5668`
- Figma 11~20 lower row: `24:5670`
- 기존 크리에이터 랭킹 위젯 PRD: `docs/prd/20260520_크리에이터랭킹위젯컴포넌트_prd.md`
- 기존 홈 추천 PRD: `docs/20260601_메인_홈_추천_UI와_API_연동/prd.md`
- 기존 위젯 패키지: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking`
@@ -202,6 +252,15 @@
---
## 13. Verification Log
- 2026-06-29: 크리에이터 랭킹 view rewrite를 완료했다. `CreatorRankingAdapter`는 ranks `1~10` top-card와 ranks `11+` lower-row 두 view type만 사용하며, `CreatorRankingTopCardView``CreatorRankingLowerRowView``ConstraintLayout` 기반으로 작성했다. 기존 `FrameLayout` 기반 Large/Compact/Horizontal view와 layout calculator/card variant는 creator ranking 경로에서 제거했다.
- 2026-06-29: rank change와 `NEW` 표시는 `CreatorRankingDeltaPresentation`, API mapper 의미는 `HomeCreatorRankingMappers`를 유지했다. 텍스트 크기는 `CreatorRankingTextStyle` 순수 로직으로 분리했고, 테스트는 placement/text-style/mapper/delta/item behavior 같은 logic contract 중심으로 정리했다. 최종 검증으로 creator ranking tests, main.home tests, mapper test, `mergeDebugResources`, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`, obsolete reference/FrameLayout 검색이 통과했다.
- 2026-06-29: 후속 요구사항으로 8~10위 Compact small에서 rank와 rank-num 간격이 넓고 `10` 표시가 답답해 보이는 문제를 확인했다. 원인은 rank `TextView`가 아니라 `CreatorRankingCompactCardView.positionSmall()`에서 parent `ll_creator_ranking_rank_group` 폭이 `52 * scale`로 고정되는 점으로 정리했다. PRD에 Compact small rank group `wrap_content`, `rank=10` 표시 보장, rank/rank-num 간격 축소, 닉네임 13sp 요구사항을 반영했다.
- 2026-06-29: Compact small 보정으로 `ll_creator_ranking_rank_group` width를 `WRAP_CONTENT`로 변경하고, 닉네임을 `13sp`로 줄였으며, rank-num top margin을 `-4 * scale`로 조정했다. `CreatorRankingAdapterLayoutTest`, `mergeDebugResources`, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`가 통과했다.
- 2026-06-29: 후속 요구사항으로 크리에이터 랭킹 카드 rank-area 계약을 갱신했다. 모든 variant의 `tv_creator_ranking_rank` XML/Kotlin width/height는 `wrap_content`를 유지하고, Kotlin은 Compact medium `54sp/22sp/16sp`, Compact small `36sp/14sp/14sp` 텍스트 크기와 rank group/delta spacing만 조정하며, Large/Compact/Horizontal 모두 순위 숫자와 rank-num을 rank area 중앙에 세로 정렬해야 한다.
- 2026-06-29: `CreatorRankingAdapterLayoutTest`를 새 계약으로 갱신한 뒤 production 수정 전 실행해 Large/Compact rank group 미구현 및 Horizontal rank `TextView` 고정 layoutParams 때문에 실패함을 확인했다. 이후 Large/Compact XML에 rank group을 추가하고 Kotlin의 rank `TextView` 고정 width/height 설정을 제거했으며, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`이 모두 `BUILD SUCCESSFUL`로 통과했다. `rg`로 Kotlin 내 rank `TextView` 고정 sizing 잔여 없음과 XML `wrap_content` 유지도 확인했다.
- 2026-06-29: Horizontal rank-num 간격도 XML 고정 margin 대신 Kotlin scale 기반 spacing으로 옮겼다. 재검증으로 `CreatorRankingAdapterLayoutTest`, `mergeDebugResources`, `compileDebugKotlin`이 모두 `BUILD SUCCESSFUL`로 통과했고, `rg`로 XML 고정 margin 및 Kotlin rank `TextView` 고정 sizing 잔여 없음도 확인했다.
- 2026-06-29: 후속 요구사항으로 Figma `24:5668`, `24:5661`, `24:5670`을 확인했다. 메인 홈 랭킹 탭에서 item 간격은 `8dp`, 3열 Compact는 순위 `36sp`/닉네임 `14sp`/rank-num 숫자 `14sp`, 2열 Compact는 순위 `54sp`/닉네임 `22sp`/rank-num 숫자 `16sp`, 11위 이후 Horizontal은 순위와 New/순위 변동 표시가 rank group 내부에서 바로 이어지는 세로 배치를 유지해야 한다.
- 2026-06-29: 위 기준에 따라 `CreatorRankingCompactCardView`, `CreatorRankingAdapter`, `HomeMainFragment`를 최소 수정했다. 3열 Compact 순위/변동 텍스트 크기, item 폭 계산 기준, 실제 RecyclerView item decoration 간격 `8dp`를 보정했고, 2열 Compact 및 11위 이후 Horizontal 배치는 회귀 테스트로 유지했다. 리뷰 게이트에서 지적된 실제 `GridLayoutManager` 간격 보장 문제는 전용 `CreatorRankingItemDecoration`과 Fragment 연결 테스트를 추가해 보완했다.
- 2026-06-25: Figma `24:5659`의 rank component screenshot을 확인했다. rank 숫자와 rank-num은 좌표상 분리되어 있으나, 실제 구현에서는 `includeFontPadding=false`가 이미 적용된 상태에서도 Pattaya glyph가 고정 rank box 하단에 붙어 보여 rank-num과 시각적으로 붙는 문제가 발생할 수 있음을 확인했다.
- 2026-06-25: 후속 요구사항으로 크리에이터 랭킹 카드의 기존 Figma 위치값(`rank-num` top: 1위 116, 2~7위 70, 8~10위 50, 11위 이후 rank group 내부 배치)은 유지하고, 순위 숫자 `TextView` 내부 하단 padding만 보정해 폰트별 glyph 하단 차이가 rank-num 간격을 침범하지 않도록 한다.
- 2026-06-25: 크리에이터 랭킹 Large/Compact/Horizontal 카드에 scale 기반 하단 padding을 적용하고, `CreatorRankingAdapterLayoutTest`에서 `includeFontPadding=false`와 padding 값을 검증하도록 했다. `CreatorRankingAdapterLayoutTest`, `creatorranking.*`, `mergeDebugResources`, `compileDebugKotlin`은 통과했고, `ktlintCheck`는 기존 전역 위반으로 실패했다.
@@ -215,3 +274,5 @@
- 2026-06-09: 순위 TextView 내부 중앙 정렬 보정 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`, `./gradlew :app:mergeDebugResources`가 모두 `BUILD SUCCESSFUL`로 통과했다.
- 2026-06-09: Figma `24:5658` design context와 screenshot을 재확인해 순위 숫자 박스 및 rank-num 위치의 최신 기준값을 후속 보정 요구사항으로 반영했다.
- 2026-06-09: 최신 Figma 수치 보정 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapterLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:ktlintCheck`가 모두 `BUILD SUCCESSFUL`로 통과했다.
- 2026-06-29: 후속 rewrite 범위로 크리에이터 랭킹 view를 새로 작성하는 요구사항을 추가했다. 신규 view는 `ConstraintLayout`을 사용하고 `FrameLayout`을 사용하지 않으며, adapter view type은 ranks `1~10`과 ranks `11~20` 두 개만 둔다. `rank > 20`은 이번 문서 갱신에서 mapper/API 동작을 바꾸지 않고, 별도 요구 전까지 현재 lower-row 처리를 유지할 수 있다고 기록했다.
- 2026-06-29: Figma `24:5659`, `24:5660`, `24:5668`, `24:5670` 기준과 ranks `1~10`, ranks `11~20` 텍스트 크기 정책을 추가했다. rank change와 `NEW` 로직은 `CreatorRankingDeltaPresentation``HomeCreatorRankingMappers` 의미를 유지하고, 테스트는 placement, text-style model, mapper, delta presentation, item behavior 같은 logic contract 중심으로 제한한다고 기록했다.

View File

@@ -1567,6 +1567,58 @@
---
### Phase 18: 탭 전환 자동 스크롤 제거
- [x] **Task 18.1: 탭 전환 scroll 보정 제거 RED 테스트 작성**
- 수정:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt`
- 작업:
- 기존 `탭 전환은 sticky tabbar anchor 아래로 내려간 scroll 위치를 되돌리지 않고 부족할 때만 보정한다` source 계약을 변경한다.
- 새 source 계약은 `onPageSelected(position)`에서 `adjustCreatorChannelStickyAnchorOnTabSelected(position)`를 호출하지 않는지 검증한다.
- 새 source 계약은 `private fun adjustCreatorChannelStickyAnchorOnTabSelected(position: Int)`와 탭 전환용 `binding.nestedScrollView.scrollTo(0, stickyScrollY)` 보정이 제거되는지 검증한다.
- 동시에 `updateOwnerFabVisibility()`, `updateDonationFloatingButtonVisibility()`, `updateOwnerCtaVisibility()`, `updateCreatorChannelTabViewportHeight()`, `updateViewPagerHeight()` 호출은 유지되는지 검증한다.
- 검증:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest"`
- 기대 결과:
- production 변경 전에는 탭 전환 scroll 보정 함수와 호출이 남아 있어 RED 실패한다.
- 검증 기록:
- 2026-06-27: `CreatorChannelActivitySourceTest`의 기존 탭 전환 보정 source 계약을 `탭 전환은 공통 scroll 위치를 자동 보정하지 않는다`로 변경했다. production 변경 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest.탭 전환은 공통 scroll 위치를 자동 보정하지 않는다"`를 실행해 `CreatorChannelActivitySourceTest.kt:1164`에서 기존 `lastSelectedCreatorChannelTabPosition` 상태가 남아 있어 RED 실패함을 확인했다.
- [x] **Task 18.2: `CreatorChannelActivity` 탭 전환 자동 스크롤 제거**
- 수정:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt`
- 작업:
- `onPageSelected(position)`에서 `adjustCreatorChannelStickyAnchorOnTabSelected(position)` 호출을 제거한다.
- `adjustCreatorChannelStickyAnchorOnTabSelected(position)` 함수와 해당 함수만을 위한 `lastSelectedCreatorChannelTabPosition` 상태를 제거한다.
- 탭 전환 시 `binding.nestedScrollView.scrollTo(0, stickyScrollY)` 또는 `smoothScrollTo`를 새로 추가하지 않는다.
- 기존 sticky title-bar/tab-bar 계산에 필요한 `calculateCreatorChannelStickyScrollY()``calculateCreatorChannelTabEmptyMinHeight()` 등 다른 경로에서 사용 중이면 유지한다.
- owner FAB/CTA 표시 갱신, 탭별 최초 로드 callback, ViewPager 높이 갱신, load-more viewport 계산은 기존 호출 순서를 유지한다.
- 검증:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest"`
- `./gradlew :app:compileDebugKotlin`
- `git diff --check`
- 기대 결과:
- focused source test와 Kotlin compile이 통과한다.
- diff에 탭 전환 자동 scroll 제거 외 불필요한 refactor가 포함되지 않는다.
- 검증 기록:
- 2026-06-27: `CreatorChannelActivity.kt`에서 `onPageSelected(position)``adjustCreatorChannelStickyAnchorOnTabSelected(position)` 호출, `adjustCreatorChannelStickyAnchorOnTabSelected(position)` 함수, 해당 함수 전용 상태 `lastSelectedCreatorChannelTabPosition`을 제거했다. `calculateCreatorChannelStickyScrollY()``calculateCreatorChannelTabEmptyMinHeight()`에서 계속 사용하므로 유지했다. 구현 후 focused GREEN으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest.탭 전환은 공통 scroll 위치를 자동 보정하지 않는다"``BUILD SUCCESSFUL`로 PASS했다.
- 2026-06-27 코드 리뷰: diff를 재확인해 `CreatorChannelActivity.kt` 변경이 탭 전환 scroll 보정 제거와 해당 상태 제거에 한정되어 있고, `onPageSelected(position)`의 owner FAB/후원 floating button/owner CTA/탭 viewport/ViewPager 높이 갱신 및 탭별 selected callback 호출은 유지됨을 확인했다. 추가 수정이 필요한 finding은 발견하지 못했다.
- [x] **Task 18.3: 탭 전환 수동 동작 확인**
- 확인:
- 크리에이터 채널 진입 후 header 상단 위치에서 다른 탭을 선택해도 화면이 자동으로 sticky tab-bar 위치까지 내려가지 않는다.
- sticky tab-bar anchor 이전 위치에서 다른 탭을 선택해도 현재 scroll 위치가 유지된다.
- sticky tab-bar anchor 이후 또는 목록 하단 근처에서 다른 탭을 선택해도 최상단으로 되돌아가지 않는다.
- 탭 전환 후 각 탭의 최초 로드, empty 최소 높이, pagination bottom 감지는 기존처럼 동작한다.
- 기대 결과:
- 탭 이동만으로 scroll 위치가 바뀌지 않고, 컨텐츠 확인을 위한 이동은 사용자 직접 스크롤로만 발생한다.
- 연결된 device/emulator가 없어 수동 확인하지 못하면 `adb devices` 결과와 함께 차단 기록을 남긴다.
- 검증 기록:
- 2026-06-27: source 계약과 production diff 기준으로 탭 클릭/`ViewPager2` page selected 경로에서 `scrollTo(0, stickyScrollY)`/`smoothScrollTo(0, stickyScrollY)` 보정이 제거되었고, owner FAB/후원 floating button/owner CTA/탭 viewport/ViewPager 높이 갱신 호출은 유지됨을 확인했다. `adb devices`에서 `2cec640c34017ece device` 연결을 확인했고, `./gradlew :app:assembleDebug``adb install -r "app/build/outputs/apk/debug/app-debug.apk"`가 성공했다. 다만 `adb shell am start -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity --el extra_creator_id 100``not exported` permission denial로 차단되어 shell 직접 실행 기반 화면 조작 검증은 수행하지 못했다.
---
## Verification Log
- 2026-06-12: `docs/20260611_크리에이터_채널_홈_탭/prd.md`, `docs/agent-guides/work-plan-docs.md`, `docs/agent-guides/build-test-style.md`, `docs/agent-guides/code-style.md`를 확인해 계획 문서 작성 규칙과 검증 명령 규칙을 확인했다.
- 2026-06-12: 기존 `HomeRecommendationApi`, `HomeRecommendationModels`, `HomeRecommendationRepository`, `HomeRecommendationViewModel`, `RecommendedActivityType`, `HomeRecommendationMappers`, `HomeRecommendationUiModels`, `AppDI`, `ChatRoomActivity`, `DmChatRoomActivity`를 확인해 신규 크리에이터 채널 홈 구현 계획의 파일 경계와 재사용 지점을 정리했다.
@@ -1654,3 +1706,7 @@
- 2026-06-25: 후속 UI 보정 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.widget.SeriesContentCardViewTest" --tests "kr.co.vividnext.sodalive.v2.widget.AudioContentCardViewTest"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행해 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 PASS했다. `ktlintCheck``.editorconfig disabled_rules` deprecation warning과 Gradle deprecation warning은 기존 경고로 이번 변경과 무관하다.
- 2026-06-25: 실제 화면 수동 검증 가능 여부 확인을 위해 `adb devices`를 실행했으나 연결된 device/emulator가 없어 전면 화면 육안 검증은 수행하지 못했다. 이번 후속 보정은 source/widget 테스트와 Gradle 리소스/컴파일/스타일 검증으로 확인했다.
- 2026-06-27: 사용자 후속 요구사항에 따라 탭 전환 시 `CreatorChannelActivity`의 공통 `NestedScrollView`를 최상단 또는 sticky tab-bar anchor 위치로 자동 스크롤하지 않도록 Phase 18을 추가했다. 구현 범위는 `CreatorChannelActivity.kt`의 탭 전환 scroll 보정 제거와 `CreatorChannelActivitySourceTest.kt` source 계약 갱신으로 제한했다. 이번 단계는 문서 수정만 수행했으며 구현/빌드/테스트는 실행하지 않았다.
- 2026-06-27: Phase 18 구현 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:assembleDebug`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행해 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 PASS했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.*"`는 303개 중 `CreatorChannelHomeViewModelTest > 채널 후원 성공은 기존 후원 API를 호출하고 홈을 다시 로드한다` 1건이 한 차례 실패했으나, 동일 테스트 단독 재실행 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelHomeViewModelTest.채널 후원 성공은 기존 후원 API를 호출하고 홈을 다시 로드한다"``BUILD SUCCESSFUL`로 PASS했다. 이번 Phase 18 변경은 Activity 탭 전환 scroll source 제거에 한정되어 해당 ViewModel 후원 테스트 실패와 직접 관련이 없다.
- 2026-06-27 Phase 18 코드 리뷰 및 재검증: `git diff` 기준 변경 범위가 `CreatorChannelActivity.kt`의 탭 전환 scroll 보정 제거, `CreatorChannelActivitySourceTest.kt` source 계약 갱신, 문서 기록에 한정됨을 확인했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.*"`를 실행해 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 PASS했다.

View File

@@ -508,6 +508,7 @@ tab-bar는 스크롤 중 title-bar 하단에 고정되는 sticky 영역으로
- 본인 페이지 Floating Button의 `커뮤니티 글 올리기`, `오디오 콘텐츠 올리기`, `라이브 만들기`는 기존 작성/업로드/라이브 생성 진입점이 있으면 재사용하고, 없으면 구현 계획에서 별도 Task로 확정한다.
- `RecommendedActivityType``CreatorActivityType`으로 변경하고 공용 패키지로 이동할 때, 홈 추천 API의 기존 참조도 함께 갱신한다.
- navigation extra 이름, analytics/logging은 근거 파일이나 별도 요구가 확인되기 전까지 임의로 추가하지 않는다.
- 탭 전환 시 공통 scroll 위치를 자동으로 최상단 또는 sticky tab-bar anchor로 이동시키는 별도 보정 로직을 추가하거나 유지하지 않는다.
---
@@ -560,6 +561,8 @@ tab-bar는 스크롤 중 title-bar 하단에 고정되는 sticky 영역으로
- `라이브`, `오디오`, `시리즈`, `커뮤니티`, `팬Talk`, `후원` 탭 상세 화면은 이번 구현 범위로 사용되지 않는다.
- 탭 바 아래 홈 컨텐츠는 Figma `296:14895` 기준으로 기존 동적 조립 UI를 삭제하고 섹션별 Figma component 기반 UI로 재구성된다.
- 홈 컨텐츠 재구성에서 `화보` 섹션은 현재 API 응답에 데이터가 없으므로 구현 범위에서 제외한다.
- 탭 이동 시 공통 scroll 위치가 자동으로 최상단 또는 sticky tab-bar anchor로 변경되지 않는다.
- 탭 이동 후에도 owner FAB/CTA 표시 갱신, 탭별 최초 로드, ViewPager 높이 갱신, load-more viewport 계산은 기존처럼 수행된다.
---
@@ -573,6 +576,20 @@ tab-bar는 스크롤 중 title-bar 하단에 고정되는 sticky 영역으로
---
### Creator Channel Tab Switch Scroll Position Follow-up
#### Requirements
- `CreatorChannelActivity`에서 사용자가 `홈`, `라이브`, `오디오`, `시리즈`, `커뮤니티`, `팬Talk`, `후원` 탭을 이동할 때 공통 `NestedScrollView`를 자동으로 최상단 또는 sticky tab-bar anchor 위치로 스크롤하지 않는다.
- 탭 클릭과 `ViewPager2` swipe 전환 모두 동일하게 현재 scroll 위치를 유지한다.
- 탭 전환 시 기존 owner FAB 접기, owner/donation CTA 표시 갱신, 탭별 최초 로드, ViewPager 높이 갱신, load-more viewport 계산은 유지한다.
- 사용자가 직접 스크롤한 위치는 탭 전환만으로 변경되지 않아야 하며, 컨텐츠를 보기 위해 필요한 스크롤은 사용자의 직접 조작에 맡긴다.
- 기존 sticky tab-bar/title-bar black 전환 동작 자체는 유지하되, 탭 전환 이벤트가 sticky 위치로 강제 이동시키는 트리거가 되면 안 된다.
#### Edge Cases
- 현재 scroll 위치가 header 상단, sticky tab-bar anchor 이전, sticky tab-bar anchor 이후, 목록 하단 어느 위치에 있어도 탭 전환만으로 `scrollTo`/`smoothScrollTo`가 실행되지 않아야 한다.
- 탭 전환 직후 탭별 fragment content height가 변경되어도 기존 ViewPager 높이 보정과 pagination bottom 재평가는 유지되어야 한다.
---
## 11. Open Questions
- 크리에이터 채널 신규 페이지의 진입점과 전달받을 Android extra 또는 navigation argument 이름은 구현 계획에서 기존 이동 패턴을 확인해 확정한다.
- 홈 탭 각 섹션의 empty 상태를 섹션 숨김으로 처리할지, 빈 상태 UI로 처리할지는 기존 크리에이터/홈 화면 패턴 확인이 필요하다.
@@ -640,3 +657,5 @@ tab-bar는 스크롤 중 title-bar 하단에 고정되는 sticky 영역으로
- 2026-06-25: 사용자 후속 요구사항에 따라 소개 섹션 본문을 Figma `296:14998` 기준 `Typography.Body3`/white/line-height 1.45로 보정하고, `spacing_14` gap과 `spacing_20` horizontal padding 및 `match_parent` 폭 유지 요구를 문서화했다.
- 2026-06-25: 후속 UI 보정 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.widget.SeriesContentCardViewTest" --tests "kr.co.vividnext.sodalive.v2.widget.AudioContentCardViewTest"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행해 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 PASS했다. `ktlintCheck``.editorconfig disabled_rules` deprecation warning과 Gradle deprecation warning은 기존 경고로 이번 변경과 무관하다.
- 2026-06-27: 사용자 후속 요구사항에 따라 `CreatorChannelActivity`의 탭 이동 시 공통 `NestedScrollView`를 최상단 또는 sticky tab-bar anchor 위치로 자동 스크롤하지 않는 요구사항을 추가했다. 기존 탭별 로드, FAB/CTA 갱신, ViewPager 높이 갱신, load-more viewport 계산은 유지하는 것으로 범위를 고정했다. 이번 단계는 PRD 문서 보완만 수행했으며 구현/빌드/테스트는 실행하지 않았다.

View File

@@ -0,0 +1,77 @@
# release 서명 정보 입력 구성 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `release` build variant가 git에 비밀값을 남기지 않고 서명 정보를 입력받아 실행될 수 있게 한다.
**Architecture:** `app/build.gradle``local.properties`의 release signing 값을 읽고 `signingConfigs.release`에 연결한다. release 관련 task 실행 시 필수 값이 누락되면 Gradle 구성은 유지하되 실행 직전에 명확한 오류를 낸다.
**Tech Stack:** Android Gradle Plugin 8.13.0, Groovy Gradle DSL, `local.properties`, `.gitignore`
---
## File Structure
- Modify: `app/build.gradle` - `local.properties` 로더, release signing config, release task 검증을 추가한다.
- Modify: `.gitignore` - 키스토어 확장자 ignore 규칙을 활성화한다.
- Create: `docs/20260627_release_서명_정보_입력_구성/prd.md` - 요구사항을 기록한다.
- Create/Modify: `docs/20260627_release_서명_정보_입력_구성/plan-task.md` - 구현 계획과 검증 기록을 누적한다.
### Phase 1: 문서 준비
- [x] **Task 1.1: PRD 문서 작성**
- 파일: `docs/20260627_release_서명_정보_입력_구성/prd.md`
- 검증 기준: release signing 입력 위치, 비밀값 미커밋, 키스토어 ignore 요구사항이 명시되어야 한다.
- 검증 기록: 2026-06-27 문서 생성 시 요구사항을 PRD에 반영했다.
- [x] **Task 1.2: 구현 계획 작성**
- 파일: `docs/20260627_release_서명_정보_입력_구성/plan-task.md`
- 검증 기준: 변경 파일, phase, task, 검증 명령이 명시되어야 한다.
- 검증 기록: 2026-06-27 구현 전 변경 파일과 검증 기준을 계획 문서에 기록했다.
### Phase 2: release signing 입력 연결
- [x] **Task 2.1: local.properties 기반 signing config 추가**
- 파일: `app/build.gradle`
- 구현 내용:
- `local.properties``Properties`로 읽는다.
- `RELEASE_STORE_FILE`, `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_ALIAS`, `RELEASE_KEY_PASSWORD`를 release signing config에 연결한다.
- `buildTypes.release``signingConfig signingConfigs.release`를 지정한다.
- 검증 기준:
- `app/build.gradle`에 실제 비밀번호나 alias 값이 하드코딩되지 않아야 한다.
- `./gradlew tasks --all`이 성공해야 한다.
- 검증 기록: 2026-06-27 `app/build.gradle``local.properties` 기반 release signing config를 추가하고 실제 비밀번호/alias 값은 하드코딩하지 않았다. `./gradlew tasks --all` 실행 결과 BUILD SUCCESSFUL을 확인했다.
- [x] **Task 2.2: release task 누락 값 검증 추가**
- 파일: `app/build.gradle`
- 구현 내용:
- release 관련 task 실행 시 필수 property 누락 목록을 포함한 `GradleException`을 발생시킨다.
- debug 관련 task는 release signing 값이 없어도 실행 가능해야 한다.
- 검증 기준:
- `./gradlew :app:assembleRelease` 실행 시 실제 서명 값이 없으면 누락 property명을 안내해야 한다.
- `./gradlew tasks --all`은 release signing 값 없이도 성공해야 한다.
- 검증 기록: 2026-06-27 `./gradlew :app:assembleRelease` 실행 시 `RELEASE_STORE_FILE`, `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_ALIAS`, `RELEASE_KEY_PASSWORD` 누락 안내와 함께 의도된 실패를 확인했다. `./gradlew :app:compileDebugKotlin`은 BUILD SUCCESSFUL로 완료되어 debug 경로가 release signing 값 없이 동작함을 확인했다.
### Phase 3: git 비밀값 보호
- [x] **Task 3.1: 키스토어 ignore 규칙 활성화**
- 파일: `.gitignore`
- 구현 내용:
- `*.jks`, `*.keystore`, `*.p12`, `*.pem`, `*.key`를 ignore한다.
- 기존 `local.properties` ignore 규칙은 유지한다.
- 검증 기준:
- `.gitignore`에 키스토어 확장자가 주석이 아닌 ignore 규칙으로 존재해야 한다.
- 검증 기록: 2026-06-27 `.gitignore`에서 `*.jks`, `*.keystore`, `*.p12`, `*.pem`, `*.key`가 주석이 아닌 ignore 규칙으로 존재함을 확인했다. 기존 `local.properties` ignore 규칙도 유지했다.
### Phase 4: 검증
- [x] **Task 4.1: Gradle 설정 검증**
- 파일: `docs/20260627_release_서명_정보_입력_구성/plan-task.md`
- 실행 명령:
- `./gradlew tasks --all`
- `./gradlew :app:assembleRelease`
- `./gradlew :app:compileDebugKotlin`
- 기대 결과:
- `tasks --all`은 성공한다.
- 실제 release signing 값이 없는 환경에서는 `assembleRelease`가 누락 property명을 포함한 오류로 실패한다.
- debug Kotlin 컴파일은 release signing 값 없이 성공한다.
- 검증 기록: 2026-06-27 `./gradlew tasks --all`은 BUILD SUCCESSFUL, `./gradlew :app:assembleRelease`는 release signing property 4개 누락 안내로 의도된 실패, `./gradlew :app:compileDebugKotlin`은 BUILD SUCCESSFUL을 확인했다.
## Verification Log
- 2026-06-27: 구현 전 `app/build.gradle``signingConfigs`가 없고 `.gitignore``local.properties`가 ignore되어 있음을 확인했다.
- 2026-06-27: 최초 `./gradlew tasks --all` 검증에서 Gradle `plugins {}` 블록 앞 실행문 배치 오류를 확인했다. `local.properties` 로딩 코드를 `plugins {}` 뒤로 이동한 뒤 동일 명령이 BUILD SUCCESSFUL로 완료됐다.

View File

@@ -0,0 +1,59 @@
# PRD: release 서명 정보 입력 구성
## 1. Overview
`release` build variant를 실행할 수 있도록 Android release signing 입력 위치를 마련한다.
## 2. Problem
- 현재 `app/build.gradle`에는 release signing 설정이 없어 Android Studio에서 `release` build variant 실행에 필요한 서명 정보를 연결할 수 없다.
- `build.gradle`에 key password, key store password, key alias를 직접 쓰면 저장소에 비밀값이 포함될 위험이 있다.
- git에 키스토어 파일이나 서명 비밀값이 포함되지 않아야 한다.
## 3. Goals
- `local.properties`에 release 서명 정보를 입력할 수 있다.
- `app/build.gradle`에는 실제 key password, key store password, key alias 값이 남지 않는다.
- git ignore 규칙으로 키스토어 파일이 실수로 커밋되지 않도록 한다.
- release 빌드/실행 시 서명 정보가 누락되면 어떤 값을 입력해야 하는지 알 수 있는 오류를 제공한다.
## 4. Non-Goals
- 실제 키스토어 파일이나 실제 비밀번호 값을 생성하거나 저장하지 않는다.
- 기존 `debug` build variant 설정은 변경하지 않는다.
- `BuildConfig`에 존재하는 기존 값 정리는 이번 범위에 포함하지 않는다.
## 5. Target Users
- Android Studio에서 `release` build variant로 앱을 실행하거나 release APK/AAB를 생성해야 하는 개발자.
## 6. User Stories
- 개발자는 `local.properties``RELEASE_STORE_FILE`, `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_ALIAS`, `RELEASE_KEY_PASSWORD`를 입력하고 release variant를 실행하고 싶다.
- 개발자는 서명 비밀값이 `build.gradle`이나 git에 포함되지 않기를 원한다.
## 7. Core Features
### Feature A: release signing properties
`app/build.gradle`에서 `local.properties`를 읽어 release signing config에 연결한다.
#### Requirements
- `RELEASE_STORE_FILE`은 키스토어 파일 경로로 사용한다.
- `RELEASE_STORE_PASSWORD`는 key store password로 사용한다.
- `RELEASE_KEY_ALIAS`는 key alias로 사용한다.
- `RELEASE_KEY_PASSWORD`는 key password로 사용한다.
- 위 값은 `build.gradle`에 하드코딩하지 않는다.
- release 관련 Gradle task 실행 시 누락된 키가 있으면 명확한 오류를 발생시킨다.
#### Edge Cases
- `local.properties`가 없거나 일부 값이 비어 있으면 debug 빌드 설정에는 영향을 주지 않는다.
- release 관련 task 실행 시 누락된 값 목록을 안내한다.
### Feature B: git 비밀값 보호
키스토어 확장자를 `.gitignore`에서 실제 ignore 대상으로 활성화한다.
#### Requirements
- `*.jks`, `*.keystore`, `*.p12`, `*.pem`, `*.key`를 ignore한다.
- `local.properties`는 기존처럼 ignore 상태를 유지한다.
## 8. Technical Constraints
- Android Gradle Groovy DSL인 `app/build.gradle`을 사용한다.
- 저장소 루트에서 Gradle 명령을 실행한다.
- 변경은 `app/build.gradle`, `.gitignore`, 작업 문서로 제한한다.
## 9. Open Questions
- 없음. 키스토어 파일 경로까지 `local.properties`에 입력하는 설계로 진행한다.

View File

@@ -0,0 +1,712 @@
# 콘텐츠 전체보기 구현 계획/TASK
> **For agentic workers:** REQUIRED SUB-SKILL: 구현 시 `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`를 사용해 task 단위로 진행한다. 각 단계는 체크박스(`- [ ]`)로 추적하고, 완료 즉시 `- [x]`로 갱신한다. 구현 범위 변경이 생기면 이 문서를 먼저 수정한 뒤 코드에 반영한다.
**Goal:** 콘텐츠 추천 섹션의 전체보기 chevron을 연결하고, 기존 콘텐츠 `전체` 탭 이동을 먼저 적용한 뒤 `New&Hot` 전용 신규 전체보기 화면을 제공한다.
**Architecture:** 구현 순서는 `chevron 표시 -> 기존 콘텐츠 전체 탭 이동 -> 신규 전체보기 페이지/API`로 고정한다. 기존 화면 이동은 `MainV2Activity`, `ContentMainFragment`, `ContentAllTabViewModel`에 작은 public navigation contract를 추가해 처리한다. 신규 전체보기 화면은 마지막 단계에서 `kr.co.vividnext.sodalive.v2.main.content.overview` 하위 Activity/ViewModel/API/Repository/DTO/UI model/adapter로 격리한다.
**Tech Stack:** Kotlin, Android XML Views, ViewBinding, RecyclerView, Retrofit, Gson, RxJava3, Koin, JUnit4/Robolectric local unit test.
---
## 전제와 성공 기준
- PRD: `docs/20260627_콘텐츠_전체보기/prd.md`
- 구현 순서:
- 1순위: 콘텐츠 추천 섹션 chevron 표시.
- 2순위: 신규 페이지가 필요 없는 섹션을 기존 콘텐츠 `전체` 탭으로 이동.
- 3순위: 신규 콘텐츠 전체보기 페이지/API 생성 및 신규 페이지 대상 섹션 이동.
- 전체보기 chevron 표시 섹션:
- 콘텐츠 추천 탭: `오직 보이스온에서만!`, `새로 올라온 오디오`, `New&Hot`, `무료 오디오`, `포인트 오디오`
- 기존 콘텐츠 `전체` 탭 이동 섹션:
- `오직 보이스온에서만!` -> 콘텐츠 탭 `전체` -> `오리지널` 카테고리
- `새로 올라온 오디오` -> 콘텐츠 탭 `전체` -> `오디오` 카테고리
- `무료 오디오` -> 콘텐츠 탭 `전체` -> `무료` 카테고리 -> `인기순`
- `포인트 오디오` -> 콘텐츠 탭 `전체` -> `포인트` 카테고리 -> `인기순`
- 신규 전체보기 페이지 이동 섹션:
- 콘텐츠 추천 탭 `New&Hot` -> 신규 콘텐츠 전체보기 -> `NEW_AND_HOT_AUDIO`
- `댓글 많은 오디오`, `추천 오디오`에는 chevron을 표시하지 않는다.
- 신규 API endpoint는 `GET /api/v2/contents`이다.
- 신규 API query parameter key는 소문자 `page`, `size`, `type`만 사용한다.
- 신규 API 기본값은 `page=0`, `size=20`, `type=NEW_AND_HOT_AUDIO`다.
- 앱 구현 DTO annotation은 Gson `@SerializedName`을 사용한다.
- 신규 전체보기 화면 title은 `NEW_AND_HOT_AUDIO`일 때 `New&Hot`이다.
- 신규 전체보기 화면은 Figma node `482:15105` 기준 2열 오디오 카드 grid를 사용한다.
- 구현 완료 후 최소 다음 명령을 실행한다.
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"`
- `./gradlew :app:mergeDebugResources`
- `./gradlew :app:compileDebugKotlin`
- `./gradlew :app:ktlintCheck`
- `git diff --check`
---
## Phase 순서
- Phase 1: 기존 구조 확인과 작업 경계 고정
- Phase 2: 홈/콘텐츠 추천 섹션 chevron 표시
- Phase 3: 기존 콘텐츠 `전체` 탭 이동 contract 추가
- Phase 4: 기존 콘텐츠 `전체` 탭 이동 섹션 연결
- Phase 5: 신규 전체보기 API, DTO, Repository, UI model 추가
- Phase 6: 신규 전체보기 ViewModel, Activity, Adapter 구현
- Phase 7: 신규 전체보기 대상 섹션 이동 연결
- Phase 8: 통합 검증과 수동 확인
---
## Figma 참조 필요 Phase
- Phase 1: 제한 참조
- 기존 홈/콘텐츠 추천 섹션 title, 콘텐츠 전체 탭, MainV2 navigation 구조 확인 중심으로 진행한다.
- Phase 2: 제한 참조
- chevron 표시는 기존 `view_section_title.xml`과 홈/콘텐츠 추천 섹션 구조를 따른다.
- Phase 3~4: Figma 참조 불필요
- 기존 콘텐츠 `전체` 탭 이동은 기존 `ContentAllTabViewModel`의 type/sort 계약과 MainV2 tab 전환 구조를 따른다.
- Phase 5: Figma 참조 불필요
- API/DTO/Repository/UI model/mapper/ViewModel은 PRD 서버 계약과 기존 V2 data layer/paging 패턴을 따른다.
- Phase 6: 필수 참조
- 신규 전체보기 Activity layout은 Figma `482:15105`의 title bar, black background, 2열 grid, card spacing을 기준으로 확인한다.
- Phase 7~8: 필수 참조
- 신규 페이지 대상 섹션 이동과 최종 수동 화면 검증은 PRD의 Figma reference와 실제 화면을 대조한다.
---
## 파일 구조
### 기존 화면 이동 선행 범위
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
- 콘텐츠 추천 섹션 chevron 표시, 기존 콘텐츠 `전체` 탭 이동, 신규 페이지 이동을 단계별로 추가한다.
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt`
- 외부 진입용 type/sort 선택 API를 추가한다.
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- 콘텐츠 탭 내부 `전체`를 특정 type/sort로 여는 public method와 기존 fragment 재사용 처리를 추가한다.
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt`
- 콘텐츠 추천 섹션 chevron/routing/source를 검증한다.
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModelTest.kt`
- 외부 type/sort 선택 API를 검증한다.
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt`
- 콘텐츠 탭 내부 `전체` type/sort routing contract를 검증한다.
### 신규 전체보기 후행 범위
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/data/ContentOverviewApi.kt`
- `GET /api/v2/contents` Retrofit endpoint를 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/data/ContentOverviewModels.kt`
- `ContentOverviewPageResponse`, `ContentOverviewType`, `ContentOverviewItemResponse` DTO를 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/data/ContentOverviewRepository.kt`
- API 호출을 repository method로 감싼다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/model/ContentOverviewUiState.kt`
- `Loading`, `Content`, `Empty`, `Error` 상태와 paging/loading-more 상태를 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/model/ContentOverviewUiModels.kt`
- 신규 전체보기 화면 item/title/type UI model을 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/model/ContentOverviewMappers.kt`
- DTO를 UI model로 변환하고 `AudioContentTag`를 매핑한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewViewModel.kt`
- type/page/hasNext/loading-more/API 상태를 관리한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivity.kt`
- title bar, 2열 RecyclerView, paging, empty/error/loading, 상세 routing을 연결한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ui/ContentOverviewAdapter.kt`
- `AudioContentCardView` 기반 2열 grid item을 바인딩한다.
- Create: `app/src/main/res/layout/activity_content_overview.xml`
- 신규 전체보기 화면 layout이다.
- Modify: `app/src/main/AndroidManifest.xml`
- `ContentOverviewActivity`를 등록한다.
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
- 신규 API, Repository, ViewModel을 Koin에 등록한다.
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewMapperTest.kt`
- DTO -> UI model/tag/title mapping을 검증한다.
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewViewModelTest.kt`
- 첫 페이지, load-more, 실패, stale response 방지를 검증한다.
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivitySourceTest.kt`
- layout id, 2열 grid, intent extra, 상세 이동 source를 검증한다.
---
### Phase 1: 기존 구조 확인과 작업 경계 고정
- [x] **Task 1.1: PRD와 기존 구현 상태 대조**
- 확인:
- `docs/20260627_콘텐츠_전체보기/prd.md`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
- `app/src/main/res/layout/view_section_title.xml`
- 작업:
- `HomeMainFragment``ViewSectionTitleBinding.setTitle(titleResId, showMore)`가 chevron 표시를 이미 지원하는지 확인한다.
- `ContentMainFragment``ViewSectionTitleBinding.setTitle(titleResId)`가 현재 chevron을 항상 숨기는지 확인한다.
- 이번 작업에서 레거시 `audio_content/*` 파일을 직접 수정하지 않는 것을 확인한다.
- 검증:
- Run: `rg -n "setTitle\\(|ivSectionTitleChevron|viewHomeFirstAudioTitle|viewContentNewAndHotTitle|viewContentOriginalSeriesTitle" app/src/main/java/kr/co/vividnext/sodalive/v2/main app/src/main/res/layout/view_section_title.xml`
- Expected: 홈 title helper와 콘텐츠 title helper, chevron view id가 확인된다.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 실행: `rg -n "setTitle\(|ivSectionTitleChevron|viewHomeFirstAudioTitle|viewContentNewAndHotTitle|viewContentOriginalSeriesTitle" app/src/main/java/kr/co/vividnext/sodalive/v2/main app/src/main/res/layout/view_section_title.xml`
- 결과: `view_section_title.xml``iv_section_title_chevron`, `HomeMainFragment``setTitle(titleResId, showMore)`, `ContentMainFragment`의 section title 설정 지점을 확인했다.
- 확인: 홈 title helper는 `showMore`로 chevron 표시를 이미 지원하며 `viewHomeFirstAudioTitle``showMore = true` 상태였다. 콘텐츠 title helper는 기존에 chevron을 숨기고 있어 Phase 2에서 helper 확장이 필요했다.
- 확인: 이번 Phase 1~2 범위에서는 레거시 `audio_content/*` 파일을 직접 수정하지 않았다.
- [x] **Task 1.2: 기존 콘텐츠 전체 탭 이동 가능 지점 확인**
- 확인:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/data/MainContentAllTabModels.kt`
- 작업:
- `MainV2Activity`의 bottom tab 전환과 fragment 재사용 위치를 확인한다.
- `ContentMainFragment``showAllContent()``ContentAllTabViewModel` type/sort 변경 위치를 확인한다.
- `MainContentAllType.ORIGINAL`, `AUDIO`, `FREE`, `POINT``ContentSort.POPULAR`, `LATEST`가 기존 목적지 요구를 충족하는지 확인한다.
- 검증:
- Run: `rg -n "MainContentAllType|ContentSort|showAllContent|changeType|changeSort|clickTab\\(MainV2Tab.CONTENT\\)" app/src/main/java/kr/co/vividnext/sodalive/v2/main`
- Expected: type/sort 선택과 bottom tab 전환 지점이 확인된다.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 실행: `rg -n "MainContentAllType|ContentSort|showAllContent|changeType|changeSort|clickTab\(MainV2Tab.CONTENT\)" app/src/main/java/kr/co/vividnext/sodalive/v2/main`
- 결과: `MainV2Activity.clickTab(MainV2Tab.CONTENT)`, `ContentMainFragment.showAllContent()`, `ContentAllTabViewModel.changeType/changeSort`, `MainContentAllType.ORIGINAL/AUDIO/FREE/POINT`, `ContentSort` 사용 지점을 확인했다.
- 확인: Phase 1에서는 기존 콘텐츠 전체 탭 이동 가능 지점만 확인했고, 실제 contract/API 추가는 Phase 3 범위로 남겼다.
---
### Phase 2: 홈/콘텐츠 추천 섹션 chevron 먼저 표시
- [x] **Task 2.1: 홈 추천 탭 FIRST 섹션 chevron 표시**
- 수정:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
- 테스트:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentSourceTest.kt`
- 작업:
- `viewHomeFirstAudioTitle.setTitle(..., showMore = true)` 상태를 유지하거나 누락되어 있으면 추가한다.
- 이 Task에서는 신규 페이지 이동 click listener를 연결하지 않는다.
- source test는 `viewHomeFirstAudioTitle.setTitle` 호출이 `showMore = true`임을 검증한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"`
- Expected: 홈 FIRST chevron 표시 source 테스트 PASS.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 확인: `HomeMainFragment``viewHomeFirstAudioTitle.setTitle(R.string.home_recommendation_section_first_audio_contents, showMore = true)`가 이미 적용되어 있었다.
- 2026-06-27 테스트 추가: `HomeMainFragmentSourceTest`를 생성해 FIRST 섹션 `showMore = true`와 Phase 2 범위에서 click listener가 아직 연결되지 않았음을 검증했다.
- 2026-06-27 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"`
- 결과: PASS.
- [x] **Task 2.2: 콘텐츠 추천 탭 section title helper 확장**
- 수정:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
- 테스트:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt`
- 작업:
- 콘텐츠 탭의 `ViewSectionTitleBinding.setTitle(titleResId)``setTitle(titleResId, showMore = false)`로 확장한다.
- `오직 보이스온에서만!`, `새로 올라온 오디오`, `New&Hot`, `무료 오디오`, `포인트 오디오``showMore = true`로 설정한다.
- `댓글 많은 오디오`, `추천 오디오``showMore = false`를 유지한다.
- 이 Task에서는 기존 전체 탭 이동과 신규 페이지 이동 click listener를 연결하지 않는다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
- Expected: 콘텐츠 추천 chevron 표시/숨김 source 테스트 PASS.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 RED 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
- RED 결과: 신규 `content recommendation section titles show only Phase 2 chevrons` 테스트가 `ContentMainFragmentSourceTest.kt:463`에서 실패했다. 원인은 콘텐츠 title helper가 `showMore`를 지원하지 않고 chevron을 항상 숨기는 기존 구현이었다.
- 2026-06-27 구현: `ContentMainFragment``ViewSectionTitleBinding.setTitle(titleResId, showMore = false)`를 추가하고 `오직 보이스온에서만!`, `새로 올라온 오디오`, `New&Hot`, `무료 오디오`, `포인트 오디오`에만 `showMore = true`를 지정했다. `댓글 많은 오디오`, `추천 오디오`는 기본값 `false`를 유지했다.
- 2026-06-27 GREEN 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
- GREEN 결과: PASS.
- 참고: 홈/콘텐츠 source 테스트를 병렬 실행하던 중 한 번 `:app:kspDebugKotlin` generated file 접근 오류가 발생했으나, 동일 콘텐츠 테스트 단독 재실행 시 정상 PASS했다.
---
### Phase 3: 기존 콘텐츠 전체 탭 이동 contract 추가
- [x] **Task 3.1: ContentAllTabViewModel 외부 선택 API 추가**
- 수정:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModelTest.kt`
- 작업:
- `fun selectTypeAndSort(type: MainContentAllType, sort: ContentSort)`를 추가한다.
- 함수는 `selectedType`, `selectedSort`, `selectedDayOfWeek`를 갱신하고 `page=0`부터 재조회한다.
- `ORIGINAL`, `AUDIO`, `FREE`, `POINT``dayOfWeek = null`로 요청한다.
- 기존 `changeType`, `changeSort`, `loadInitial` 테스트를 깨지 않는다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest"`
- Expected: 기존 테스트와 신규 외부 선택 테스트 PASS.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 구현: `ContentAllTabViewModel.selectTypeAndSort(type, sort)`를 추가해 `selectedType`, `selectedSort`, `selectedDayOfWeek`를 갱신하고 첫 페이지를 재조회하도록 했다. `SERIES`가 아닌 type은 `selectedDayOfWeekFor(type)` 경로로 `dayOfWeek = null`을 유지한다.
- 2026-06-27 테스트 추가: `ContentAllTabViewModelTest`에 외부 선택 요청 `FREE` + `POPULAR`, `ORIGINAL` + `LATEST`가 모두 `page=0`, `dayOfWeek=null`로 호출되는 검증을 추가했다.
- 2026-06-27 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest"`
- 결과: PASS.
- [x] **Task 3.2: ContentMainFragment에 내부 전체 탭 선택 API 추가**
- 수정:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt`
- 작업:
- `fun selectAllTab(type: MainContentAllType, sort: ContentSort = ContentSort.LATEST)`를 추가한다.
- 함수는 `binding.textTabBarContent.root`의 선택을 `전체`로 전환하고 `showContentTab(CONTENT_TAB_ALL)`을 호출한다.
- 이후 `contentAllTabViewModel.selectTypeAndSort(type, sort)`를 호출한다.
- fragment view가 아직 생성되지 않은 경우를 대비해 pending type/sort를 fragment field에 저장하고 `onViewCreated` 이후 적용한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
- Expected: 내부 전체 탭 선택 source 테스트 PASS.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 구현: `ContentMainFragment.selectAllTab(type, sort)`를 추가하고 view 생성 전 호출은 `pendingAllTabSelection`에 저장한 뒤 `onViewCreated` 이후 적용하도록 했다.
- 2026-06-27 확인: pending 적용 시 `hasSelectedAllTab = true`를 먼저 설정한 뒤 `textTabBarContent.root.selectTab(CONTENT_TAB_ALL)``showContentTab(CONTENT_TAB_ALL)`을 호출해 `TextTabBarView.selectTab` listener가 유발할 수 있는 기본 `loadInitial()` 중복 호출을 피했다. 실제 데이터 재조회는 `contentAllTabViewModel.selectTypeAndSort(...)` 단일 경로로 수행한다.
- 2026-06-27 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
- 결과: PASS.
- [x] **Task 3.3: MainV2Activity 콘텐츠 전체 탭 진입 API 추가**
- 수정:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt`
- 작업:
- `fun openContentAllTab(type: MainContentAllType, sort: ContentSort = ContentSort.LATEST)`를 추가한다.
- 현재 탭을 `MainV2Tab.CONTENT`로 전환한다.
- `changeFragment` 이후 기존 또는 신규 `ContentMainFragment``selectAllTab(type, sort)`를 전달한다.
- 이미 콘텐츠 탭에 있는 경우 fragment를 중복 생성하지 않고 현재 fragment method를 호출한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest"`
- Expected: MainV2 콘텐츠 전체 탭 routing source 테스트 PASS.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 구현: `MainV2Activity.openContentAllTab(type, sort)`를 추가해 CONTENT 탭이 아니면 `viewModel.clickTab(MainV2Tab.CONTENT)``changeFragment(MainV2Tab.CONTENT)`로 콘텐츠 Fragment를 준비한 뒤 기존/new `ContentMainFragment.selectAllTab(type, sort)`를 호출하도록 했다.
- 2026-06-27 확인: 이미 CONTENT 탭인 경우 `changeFragment`를 직접 호출하지 않고 기존 `ContentMainFragment`를 찾아 `selectAllTab`만 호출하므로 fragment 중복 생성을 피한다.
- 2026-06-27 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest"`
- 결과: PASS.
---
### Phase 4: 기존 콘텐츠 전체 탭 이동 섹션 연결
- [x] **Task 4.1: 콘텐츠 추천 탭 기존 전체 탭 목적지 연결**
- 수정:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
- 테스트:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt`
- 작업:
- `오직 보이스온에서만!` chevron은 `MainContentAllType.ORIGINAL`, `ContentSort.LATEST``MainV2Activity.openContentAllTab`을 호출한다.
- `새로 올라온 오디오` chevron은 `MainContentAllType.AUDIO`, `ContentSort.LATEST`로 호출한다.
- `무료 오디오` chevron은 `MainContentAllType.FREE`, `ContentSort.POPULAR`로 호출한다.
- `포인트 오디오` chevron은 `MainContentAllType.POINT`, `ContentSort.POPULAR`로 호출한다.
- `New&Hot`은 신규 페이지 대상이므로 이 Phase에서는 click listener를 연결하지 않는다.
-`처음부터 함께 성장!`은 신규 페이지 대상이므로 이 Phase에서는 click listener를 연결하지 않는다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
- Expected: 기존 전체 탭 이동 섹션별 목적지 source 테스트 PASS.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 구현: `오직 보이스온에서만!`, `새로 올라온 오디오`, `무료 오디오`, `포인트 오디오` chevron click listener를 `ensureMainV2NavigationAllowed`로 감싸고 `(activity as? MainV2Activity)?.openContentAllTab(...)` 경로에 연결했다.
- 2026-06-27 확인: routing destination은 각각 `ORIGINAL/LATEST`, `AUDIO/LATEST`, `FREE/POPULAR`, `POINT/POPULAR`로 지정했다. `New&Hot` 및 홈 `처음부터 함께 성장!` click listener는 Phase 4 범위에서 연결하지 않았다.
- 2026-06-27 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
- 결과: PASS.
- [ ] **Task 4.2: 기존 전체 탭 이동 수동 검증**
- 확인:
- 콘텐츠 추천 탭 `오직 보이스온에서만!` chevron 클릭 시 콘텐츠 `전체` -> `오리지널` 진입.
- 콘텐츠 추천 탭 `새로 올라온 오디오` chevron 클릭 시 콘텐츠 `전체` -> `오디오` 진입.
- 콘텐츠 추천 탭 `무료 오디오` chevron 클릭 시 콘텐츠 `전체` -> `무료` + `인기순` 진입.
- 콘텐츠 추천 탭 `포인트 오디오` chevron 클릭 시 콘텐츠 `전체` -> `포인트` + `인기순` 진입.
- 콘텐츠 추천 탭 `New&Hot`과 홈 `처음부터 함께 성장!`은 chevron만 표시되고 신규 페이지 이동은 아직 연결되지 않음.
- 검증 기록:
- 구현 시 이 Task 아래에 테스트 기기/빌드 variant/API 응답 조건과 확인 결과를 한국어로 누적한다.
- 2026-06-27 상태: BLOCKED/미완료. 현재 작업 환경에서 Android 기기 또는 에뮬레이터를 통한 실제 화면 조작을 수행할 수 없어 수동 검증을 완료하지 못했다.
- 2026-06-27 대체 확인: source 테스트로 Phase 4 click listener 연결 대상과 미연결 대상(`New&Hot`)은 검증했으나, 실제 앱 화면에서 chevron 클릭 후 콘텐츠 `전체` 탭 UI/type/sort 표시까지는 확인하지 못했다.
---
### Phase 5: 신규 전체보기 API, DTO, Repository, UI model 추가
- [x] **Task 5.1: ContentOverview API/DTO/Repository 추가**
- 생성:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/data/ContentOverviewApi.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/data/ContentOverviewModels.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/data/ContentOverviewRepository.kt`
- 수정:
- `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
- 작업:
- `ContentOverviewApi.getContents(authHeader, page, size, type)``@GET("/api/v2/contents")`로 정의한다.
- query annotation 이름은 `page`, `size`, `type`만 사용한다.
- DTO는 `@Keep``@SerializedName`을 사용한다.
- `ContentOverviewType``NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT`만 정의한다.
- Repository는 `Single<ApiResponse<ContentOverviewPageResponse>>`를 반환한다.
- Koin `networkModule`, `repositoryModule`에 신규 API/Repository를 등록한다.
- 검증:
- Run: `./gradlew :app:compileDebugKotlin`
- Expected: 신규 data layer와 DI 등록이 컴파일된다.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 구현: `ContentOverviewApi`, `ContentOverviewPageResponse`, `ContentOverviewType`, `ContentOverviewItemResponse`, `ContentOverviewRepository`를 추가했다. endpoint는 `GET /api/v2/contents`, query key는 `page`, `size`, `type`만 사용한다.
- 2026-06-27 DI 등록: `AppDI``networkModule``ContentOverviewApi`, `repositoryModule``ContentOverviewRepository`를 등록했다. ViewModel DI는 Phase 6.5에서 등록하도록 이 Task에서는 제외했다.
- 2026-06-27 실행: `./gradlew :app:compileDebugKotlin`
- 결과: PASS.
- 2026-06-27 코드 리뷰 확인: PRD/계획서의 응답 필드 계약은 `items`, `contentId`, `coverImage`인데 현재 DTO는 `contents`, `audioContentId`, `imageUrl`, `totalCount`를 사용한다. 서버 최신 계약 변경이 아니라면 API 응답 파싱 실패 위험이 있어 수정이 필요하다.
- 2026-06-27 리뷰 후속 RED 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"`
- RED 결과: 테스트 fixture를 문서 계약인 `items`, `contentId`, `coverImage`로 변경하자 기존 DTO의 `contents`, `audioContentId`, `imageUrl`, `totalCount` 생성자와 맞지 않아 컴파일 실패했다.
- 2026-06-27 리뷰 후속 구현: `ContentOverviewPageResponse``items` 기반으로, `ContentOverviewItemResponse``contentId`, `coverImage` 기반으로 수정하고 mapper도 동일 계약을 사용하도록 변경했다.
- [x] **Task 5.2: Mapper RED 테스트 작성**
- 생성:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewMapperTest.kt`
- 테스트 케이스:
- `NEW_AND_HOT_AUDIO` title은 `New&Hot`이다.
- `FIRST_AUDIO_CONTENT` title은 `처음부터 함께 성장!`이다.
- `price == 0`이면 `AudioContentTag.Free`가 포함된다.
- `isPointAvailable == true`이면 `AudioContentTag.Point`가 포함된다.
- `isFirstContent == true`이면 `AudioContentTag.First`가 포함된다.
- `isOriginalSeries == true`이면 `AudioContentTag.Original`이 포함된다.
- `isAdult == true`이면 `showAdultBadge == true`다.
- `page`, `size`, `hasNext`, `type`은 UI state에 보존된다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewMapperTest"`
- Expected: mapper 구현 전 RED 실패.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 RED 테스트 추가: `ContentOverviewMapperTest`를 생성해 type title resource, tag mapping, adult badge, paging metadata/type 보존을 검증하도록 했다.
- 2026-06-27 RED 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewMapperTest"`
- RED 결과: `overview.model` mapper/UI model 미구현으로 `Unresolved reference 'model'`, `toTitleResId`, `toUiModel`, `toContent` 컴파일 실패를 확인했다.
- [x] **Task 5.3: UI model과 mapper 구현**
- 생성:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/model/ContentOverviewUiState.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/model/ContentOverviewUiModels.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/model/ContentOverviewMappers.kt`
- 작업:
- `ContentOverviewUiState.Loading`, `Content`, `Empty`, `Error`를 정의한다.
- `ContentOverviewUiModel``contentId`, `title`, `coverImage`, `creatorNickname`, `tags`, `showAdultBadge`를 포함한다.
- `ContentOverviewType.toTitleResId()`를 정의한다.
- `NEW_AND_HOT_AUDIO``R.string.content_recommendation_section_new_and_hot`, `FIRST_AUDIO_CONTENT``R.string.home_recommendation_section_first_audio_contents`로 매핑한다.
- `ContentOverviewItemResponse.toUiModel()`에서 PRD tag mapping을 구현한다.
- `ContentOverviewPageResponse.toContent()`에서 paging metadata와 item list를 보존한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewMapperTest"`
- Expected: mapper 테스트 PASS.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 구현: `ContentOverviewUiState`, `ContentOverviewUiModel`, `ContentOverviewType.toTitleResId()`, `ContentOverviewPageResponse.toContent()`, `ContentOverviewItemResponse.toUiModel()`을 추가했다.
- 2026-06-27 확인: `NEW_AND_HOT_AUDIO``New&Hot`, `FIRST_AUDIO_CONTENT``처음부터 함께 성장!` title resource로 매핑하고, `Original/First/Point/Free` tag 및 adult badge를 DTO boolean/price 기준으로 매핑했다.
- 2026-06-27 GREEN 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewMapperTest"`
- GREEN 결과: PASS.
---
### Phase 6: 신규 전체보기 ViewModel, Activity, Adapter 구현
- [x] **Task 6.1: ViewModel RED 테스트 작성**
- 생성:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewViewModelTest.kt`
- 테스트 케이스:
- 초기 로드는 전달받은 type으로 `page=0`, `size=20`을 요청한다.
- 첫 페이지 성공 + items 있음은 `Content` 상태를 emit한다.
- 첫 페이지 성공 + items empty는 `Empty` 상태를 emit한다.
- 첫 페이지 실패는 `Error` 상태와 toast를 emit한다.
- `hasNext = true` 상태에서 `loadMore()`는 다음 page를 요청하고 append한다.
- `hasNext = false`이면 `loadMore()`가 repository를 추가 호출하지 않는다.
- 추가 페이지 실패 시 기존 items를 유지하고 pagination error message를 emit한다.
- type 변경 또는 새 화면 생성 상태 간 page/items를 공유하지 않는다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewViewModelTest"`
- Expected: ViewModel 구현 전 RED 실패.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 RED 테스트 추가: `ContentOverviewViewModelTest`를 생성해 초기 load, Content/Empty/Error, toast, load-more append, `hasNext=false` 추가 요청 방지, pagination error 보존, stale response 방지를 검증하도록 했다.
- 2026-06-27 RED 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewViewModelTest"`
- RED 결과: `ContentOverviewViewModel` 미구현으로 `Unresolved reference 'ContentOverviewViewModel'`, `loadFirstPage`, `loadMore`, `overviewStateLiveData` 컴파일 실패를 확인했다.
- [x] **Task 6.2: ViewModel 구현**
- 생성:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewViewModel.kt`
- 작업:
- `loadFirstPage(type: ContentOverviewType)`를 구현한다.
- `loadMore()``Content` 상태, `hasNext`, `isLoadingMore`를 확인한 뒤 다음 page만 요청한다.
- `requestGeneration`으로 stale response가 현재 화면 상태를 덮어쓰지 않게 한다.
- auth header는 기존 ViewModel 패턴대로 `Bearer ${SharedPreferenceManager.token}`를 사용한다.
- 첫 페이지 loading은 `isLoading` LiveData로 처리하고, 추가 페이지 loading은 `Content.isLoadingMore`로 처리한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewViewModelTest"`
- Expected: ViewModel 테스트 PASS.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 구현: `ContentOverviewViewModel`을 추가해 `page=0`, `size=20`, `Bearer ${SharedPreferenceManager.token}`, `requestGeneration`, 첫 페이지 `isLoading`, 추가 페이지 `Content.isLoadingMore`/`paginationErrorMessage` 패턴을 구현했다.
- 2026-06-27 GREEN 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewViewModelTest"`
- GREEN 결과: PASS.
- [x] **Task 6.3: Activity layout과 Adapter 구현**
- 생성:
- `app/src/main/res/layout/activity_content_overview.xml`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ui/ContentOverviewAdapter.kt`
- 수정:
- `app/src/main/AndroidManifest.xml`
- 작업:
- layout root는 black background를 사용한다.
- title bar는 60dp 높이, 좌측 back chevron, 22sp bold title을 배치한다.
- RecyclerView는 2열 `GridLayoutManager`로 표시한다.
- RecyclerView item은 기존 `item_content_audio_card.xml``AudioContentCardView`를 재사용한다.
- Adapter는 `setGridItemWidthPx(widthPx)`를 호출해 2열 item width를 적용한다.
- 이미지 로딩은 기존 `loadUrl` 확장을 사용한다.
- manifest에 `kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivity`를 등록한다.
- 검증:
- Run: `./gradlew :app:mergeDebugResources`
- Expected: 신규 layout과 resource reference가 merge된다.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 구현: Figma `482:15105` 기준으로 black root, 60dp title bar, 좌측 `ic_new_bar_back`, 22sp bold title(`Typography.Heading2`), 2열 RecyclerView layout을 추가했다.
- 2026-06-27 구현: `ContentOverviewAdapter`를 추가해 `item_content_audio_card.xml`/`AudioContentCardView`를 재사용하고 `setGridItemWidthPx`, `setContent`, `setTags`, `setAdultVisible`, `loadUrl`을 바인딩했다.
- 2026-06-27 구현: `AndroidManifest.xml``.v2.main.content.overview.ContentOverviewActivity`를 등록했다.
- 2026-06-27 실행: `./gradlew :app:mergeDebugResources`
- 결과: PASS.
- [x] **Task 6.4: Activity source RED 테스트 작성**
- 생성:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivitySourceTest.kt`
- 테스트 케이스:
- `ContentOverviewActivity.newIntent(context, type)`가 type extra를 포함한다.
- layout에 back button, title TextView, RecyclerView, empty/error TextView가 있다.
- Activity가 `GridLayoutManager(this, CONTENT_OVERVIEW_GRID_SPAN_COUNT)`로 2열 grid를 설정한다.
- scroll bottom에서 `viewModel.loadMore()`를 호출한다.
- item click은 `Constants.EXTRA_AUDIO_CONTENT_ID``AudioContentDetailActivity`를 연다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivitySourceTest"`
- Expected: Activity 구현 전 또는 연결 전 RED 실패.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 RED 테스트 추가: `ContentOverviewActivitySourceTest`를 생성해 `newIntent`, intent extra, Figma layout id, 2열 `GridLayoutManager`, 하단 scroll `loadMore`, 상세 이동 extra, manifest 등록, Phase 7 라우팅 미추가를 검증하도록 했다.
- 2026-06-27 RED 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivitySourceTest"`
- RED 결과: layout/manifest/Phase 7 미연결 검증은 통과했고 `ContentOverviewActivity.kt` 미생성으로 Activity source 관련 테스트 2건이 `FileNotFoundException`으로 실패하는 것을 확인했다.
- [x] **Task 6.5: Activity 구현과 ViewModel 연결**
- 생성:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivity.kt`
- 수정:
- `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
- 작업:
- `newIntent(context, type)` companion method를 제공한다.
- intent type extra가 없거나 enum parsing에 실패하면 `NEW_AND_HOT_AUDIO`를 fallback으로 사용한다.
- title은 type mapping으로 표시한다.
- back button은 `finish()`를 호출한다.
- `Content`, `Empty`, `Error`, `Loading` 상태별 UI를 바인딩한다.
- item click은 `AudioContentDetailActivity`를 열고 `Constants.EXTRA_AUDIO_CONTENT_ID``contentId`를 전달한다.
- 성인 콘텐츠 접근 제한은 기존 `AudioContentDetailActivity` 정책을 따른다.
- Koin `viewModelModule``ContentOverviewViewModel`을 등록한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"`
- Expected: overview mapper/ViewModel/source 테스트 PASS.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 구현: `ContentOverviewActivity`를 추가해 `newIntent(context, type)`, type title, back `finish()`, `Loading/Content/Empty/Error` 렌더링, 2열 grid width 계산, scroll bottom `loadMore()`, `AudioContentDetailActivity` 상세 이동을 연결했다.
- 2026-06-27 구현: `AppDI.viewModelModule``ContentOverviewViewModel`을 등록했다. intent type extra가 없거나 파싱 실패하면 계획서 기본값인 `NEW_AND_HOT_AUDIO`를 사용한다.
- 2026-06-27 GREEN 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"`
- GREEN 결과: PASS.
- 2026-06-27 추가 검증: 포맷 수정 후 동일 overview 테스트 재실행 결과 PASS.
- 2026-06-27 코드 리뷰 확인: PRD edge case의 `contentId <= 0` 상세 이동 무시 조건이 현재 `ContentOverviewActivity.openAudioContentDetail(contentId)` 또는 adapter click 경로에 반영되어 있지 않다. 잘못된 id 응답 시 상세 화면을 열 수 있어 수정이 필요하다.
- 2026-06-27 리뷰 후속 RED 테스트 추가: `ContentOverviewActivitySourceTest`에 Activity의 `if (contentId <= 0) return` 및 Adapter의 `return@setOnClickListener` 검증을 추가했다.
- 2026-06-27 리뷰 후속 구현: `ContentOverviewActivity.openAudioContentDetail(contentId)``ContentOverviewAdapter` click listener에 `contentId <= 0` 상세 이동 무시 조건을 추가했다.
- [x] **Task 6.6: 신규 전체보기 loading UI 표시 후속 수정**
- 수정:
- `app/src/main/res/layout/activity_content_overview.xml`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivity.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewActivitySourceTest.kt`
- 작업:
- 첫 페이지 `Loading` 상태에서 사용자에게 보이는 중앙 progress view를 표시한다.
- `Content.isLoadingMore`가 true일 때 기존 목록은 유지하고 하단 progress view를 표시한다.
- `Empty`, `Error`, `Content`의 비로딩 상태에서는 progress view를 숨긴다.
- skeleton loading, 신규 정렬/필터, Phase 7 신규 전체보기 라우팅은 추가하지 않는다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivitySourceTest"`
- Expected: first-page/loading-more progress source 테스트 PASS.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 작업 결정: PRD의 첫 페이지 로딩/추가 페이지 로딩 상태 구분 요구를 엄격히 충족하기 위해 `LoadingDialog`가 아닌 inline progress view 2개를 추가하는 최소 수정으로 진행한다.
- 2026-06-27 RED 테스트 추가: `ContentOverviewActivitySourceTest`에 first-page progress id, load-more progress id, `viewModel.isLoading.observe(this)`, `renderLoading()` progress 표시, `state.isLoadingMore`에 따른 load-more progress 표시 검증을 추가했다.
- 2026-06-27 RED 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivitySourceTest"`
- RED 결과: `ContentOverviewActivitySourceTest.kt:48`, `ContentOverviewActivitySourceTest.kt:59`에서 예상 실패했다. 원인은 `activity_content_overview.xml`에 progress view가 없고 `ContentOverviewActivity`가 로딩 progress를 바인딩하지 않았기 때문이다.
- 2026-06-27 구현: `activity_content_overview.xml``pb_content_overview_initial_loading`, `pb_content_overview_load_more`를 추가하고, `ContentOverviewActivity`에서 `viewModel.isLoading``Content.isLoadingMore`에 따라 progress visibility를 제어하도록 했다.
- 2026-06-27 GREEN 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivitySourceTest"`
- GREEN 결과: PASS.
- 2026-06-27 코드 리뷰 및 검증 재확인: 첫 페이지/loading-more progress view와 Activity binding이 유지되어 있고, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"` 재실행 결과 PASS.
---
### Phase 7: 신규 전체보기 대상 섹션 이동 연결
- [x] **Task 7.1: 홈 FIRST 신규 전체보기 이동 연결**
- 수정:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
- 테스트:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentSourceTest.kt`
- 작업:
- `viewHomeFirstAudioTitle.ivSectionTitleChevron.setOnClickListener`를 추가한다.
- 클릭 시 `ContentOverviewActivity.newIntent(requireContext(), ContentOverviewType.FIRST_AUDIO_CONTENT)`로 이동한다.
- `ensureMainV2NavigationAllowed`로 기존 홈 navigation guard 패턴을 따른다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"`
- Expected: 홈 FIRST 신규 전체보기 이동 source 테스트 PASS.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 RED 테스트 수정: `HomeMainFragmentSourceTest`의 Phase 2 미연결 assertion을 Phase 7 라우팅 assertion으로 변경해 `ContentOverviewActivity`/`ContentOverviewType` import, `viewHomeFirstAudioTitle.ivSectionTitleChevron.setOnClickListener`, `FIRST_AUDIO_CONTENT` intent 생성을 요구하도록 했다.
- 2026-06-27 RED 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"`를 production 구현 전 병렬 실행했으나 Gradle/Kotlin daemon cache 충돌 및 동시 compile 오류로 테스트 assertion 결과까지 도달하지 못했다. 이후 `./gradlew --stop`으로 daemon을 정리하고 순차 검증으로 전환했다.
- 2026-06-27 구현: `HomeMainFragment``ContentOverviewActivity`, `ContentOverviewType` import를 추가하고 `viewHomeFirstAudioTitle.ivSectionTitleChevron.setOnClickListener`에서 `ensureMainV2NavigationAllowed`를 거쳐 `ContentOverviewActivity.newIntent(requireContext(), ContentOverviewType.FIRST_AUDIO_CONTENT)`를 실행하도록 연결했다.
- 2026-06-27 GREEN 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"`
- GREEN 결과: PASS.
- 2026-06-27 리뷰 후속 보강: `HomeMainFragmentSourceTest``openFirstAudioOverview()` 함수 내부에서 `ensureMainV2NavigationAllowed``startActivity`보다 먼저 호출되고 `FIRST_AUDIO_CONTENT` intent를 생성하는 순서를 검증하도록 보강했다.
- [x] **Task 7.2: 콘텐츠 New&Hot 신규 전체보기 이동 연결**
- 수정:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
- 테스트:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt`
- 작업:
- `viewContentNewAndHotTitle.ivSectionTitleChevron.setOnClickListener`를 추가한다.
- 클릭 시 `ContentOverviewActivity.newIntent(requireContext(), ContentOverviewType.NEW_AND_HOT_AUDIO)`로 이동한다.
- `ensureMainV2NavigationAllowed`로 기존 콘텐츠 navigation guard 패턴을 따른다.
- 기존 전체 탭 이동 대상 섹션 click listener는 Phase 4 동작을 유지한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
- Expected: 콘텐츠 New&Hot 신규 전체보기 이동 source 테스트 PASS.
- 검증 기록:
- 구현 시 이 Task 아래에 명령, 결과, 확인 내용을 한국어로 누적한다.
- 2026-06-27 RED 테스트 수정: `ContentMainFragmentSourceTest``New&Hot` 미연결 assertion을 Phase 7 라우팅 assertion으로 변경해 `ContentOverviewActivity`/`ContentOverviewType` import, `viewContentNewAndHotTitle.ivSectionTitleChevron.setOnClickListener`, `NEW_AND_HOT_AUDIO` 이동 helper를 요구하도록 했다.
- 2026-06-27 RED 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`를 production 구현 전 병렬 실행했으나 Gradle/Kotlin daemon cache 충돌 및 동시 compile 오류로 테스트 assertion 결과까지 도달하지 못했다. 이후 `./gradlew --stop`으로 daemon을 정리하고 순차 검증으로 전환했다.
- 2026-06-27 구현: `ContentMainFragment``ContentOverviewActivity`, `ContentOverviewType` import를 추가하고 `viewContentNewAndHotTitle.ivSectionTitleChevron.setOnClickListener`에서 `ensureMainV2NavigationAllowed`를 거쳐 `ContentOverviewActivity.newIntent(requireContext(), ContentOverviewType.NEW_AND_HOT_AUDIO)` 경로로 이동하도록 연결했다. 기존 전체 탭 이동 대상 섹션의 `openContentAllTab` 연결은 유지했다.
- 2026-06-27 GREEN 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
- GREEN 결과: PASS. 최초 GREEN 시도에서는 source assertion이 helper 구조가 아닌 literal intent 호출을 기대해 1회 실패했고, production 코드는 유지한 채 `openContentOverview(ContentOverviewType.NEW_AND_HOT_AUDIO)`/`ContentOverviewActivity.newIntent(requireContext(), type)` 구조를 검증하도록 테스트를 조정한 뒤 PASS했다.
- 2026-06-27 리뷰 후속 보강: `ContentMainFragmentSourceTest``openContentOverview(type)` 함수 내부에서 `ensureMainV2NavigationAllowed``startActivity`보다 먼저 호출되고 `ContentOverviewActivity.newIntent(requireContext(), type)`를 실행하는 순서를 검증하도록 보강했다.
---
### Phase 8: 통합 검증과 수동 확인
- [x] **Task 8.1: 단위/source 테스트 실행**
- 실행:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"`
- 기대 결과:
- 수정된 홈/콘텐츠/MainV2 source 테스트와 신규 overview 테스트가 모두 PASS한다.
- 검증 기록:
- 구현 시 이 Task 아래에 실행 명령과 결과를 한국어로 누적한다.
- 2026-06-27 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`
- 결과: PASS.
- 2026-06-27 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`
- 결과: PASS.
- 2026-06-27 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"`
- 결과: PASS.
- [x] **Task 8.2: 빌드/리소스/린트 검증**
- 실행:
- `./gradlew :app:mergeDebugResources`
- `./gradlew :app:compileDebugKotlin`
- `./gradlew :app:ktlintCheck`
- `git diff --check`
- 기대 결과:
- resource merge, Kotlin compile, ktlint, whitespace 검증이 모두 PASS한다.
- 검증 기록:
- 구현 시 이 Task 아래에 실행 명령과 결과를 한국어로 누적한다.
- 2026-06-27 실행: `./gradlew :app:mergeDebugResources`
- 결과: PASS.
- 2026-06-27 실행: `./gradlew :app:compileDebugKotlin`
- 결과: PASS.
- 2026-06-27 실행: `./gradlew :app:ktlintCheck`
- 결과: PASS.
- 2026-06-27 실행: `git diff --check`
- 결과: PASS(출력 없음).
- [ ] **Task 8.3: 수동 화면 검증**
- 확인:
- 홈 추천 탭 `처음부터 함께 성장!` title 우측 chevron 표시.
- 콘텐츠 추천 탭 `오직 보이스온에서만!`, `새로 올라온 오디오`, `New&Hot`, `무료 오디오`, `포인트 오디오` title 우측 chevron 표시.
- 콘텐츠 추천 탭 `댓글 많은 오디오`, `추천 오디오`에는 chevron이 표시되지 않음.
- 콘텐츠 추천 탭 `오직 보이스온에서만!` chevron 클릭 시 콘텐츠 `전체` -> `오리지널` 진입.
- 콘텐츠 추천 탭 `새로 올라온 오디오` chevron 클릭 시 콘텐츠 `전체` -> `오디오` 진입.
- 콘텐츠 추천 탭 `무료 오디오` chevron 클릭 시 콘텐츠 `전체` -> `무료` + `인기순` 진입.
- 콘텐츠 추천 탭 `포인트 오디오` chevron 클릭 시 콘텐츠 `전체` -> `포인트` + `인기순` 진입.
- 홈 추천 탭 `처음부터 함께 성장!` chevron 클릭 시 `처음부터 함께 성장!` 신규 전체보기 진입.
- 콘텐츠 추천 탭 `New&Hot` chevron 클릭 시 `New&Hot` 신규 전체보기 진입.
- 신규 전체보기 화면 title bar, back button, 2열 grid, tag, 성인 badge, 상세 이동.
- 신규 전체보기 화면 하단 스크롤 시 `page + 1` 추가 요청.
- 검증 기록:
- 구현 시 이 Task 아래에 테스트 기기/빌드 variant/API 응답 조건과 확인 결과를 한국어로 누적한다.
- 2026-06-27 상태: BLOCKED/미완료. 현재 작업 환경에서 Android 기기 또는 에뮬레이터를 통한 실제 화면 조작을 수행할 수 없어 홈/콘텐츠 chevron 표시, 기존 전체 탭 이동, 신규 전체보기 화면 진입/스크롤/상세 이동을 수동으로 확인하지 못했다.
- 2026-06-27 대체 확인: Task 8.1의 source/unit 테스트와 Task 8.2의 resource merge/compile/ktlint/whitespace 검증은 모두 PASS했다. 실제 API 응답 기반 화면 표시와 사용자 조작 검증은 기기 또는 에뮬레이터가 준비된 환경에서 후속 확인이 필요하다.
---
## Verification Log
- 구현 완료 후 여러 Phase에 걸친 통합 검증, 회귀 검증, 최종 수동 확인 기록을 이 섹션에 누적한다.
- 2026-06-27 Phase 3~4 범위 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest"` 결과 PASS.
- 2026-06-27 Phase 3~4 범위 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"` 결과 PASS.
- 2026-06-27 Phase 3~4 범위 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest"` 결과 PASS.
- 2026-06-27 Phase 3~4 범위 실행: `./gradlew :app:compileDebugKotlin` 결과 PASS.
- 2026-06-27 Phase 3~4 범위 실행: `./gradlew :app:ktlintCheck`는 신규 source 테스트의 긴 줄로 1회 실패 후 포맷 수정, 재실행 결과 PASS. 기존 `.editorconfig``disabled_rules` deprecation 경고는 출력되었으나 실패 원인은 아니었다.
- 2026-06-27 Phase 3~4 범위 실행: `git diff --check` 결과 PASS(출력 없음).
- 2026-06-27 수동 화면 검증: Android 기기/에뮬레이터를 사용할 수 없어 Phase 4 기존 전체 탭 이동 수동 클릭 검증은 완료하지 못했다. Task 4.2에 BLOCKED/미완료로 기록했다.
- 2026-06-27 Phase 5~6 범위 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"` 결과 PASS.
- 2026-06-27 Phase 5~6 인접 회귀 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"` 결과 PASS.
- 2026-06-27 Phase 5~6 인접 회귀 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"` 결과 PASS.
- 2026-06-27 Phase 5~6 범위 실행: `./gradlew :app:mergeDebugResources` 결과 PASS.
- 2026-06-27 Phase 5~6 범위 실행: `./gradlew :app:compileDebugKotlin` 결과 PASS.
- 2026-06-27 Phase 5~6 범위 실행: `./gradlew :app:ktlintCheck`는 신규 overview 테스트의 긴 줄/unused import로 1회 실패 후 포맷 수정, 재실행 결과 PASS. 기존 `.editorconfig``disabled_rules` deprecation 경고는 출력되었으나 실패 원인은 아니었다.
- 2026-06-27 Phase 5~6 범위 실행: `git diff --check` 결과 PASS(출력 없음).
- 2026-06-27 Phase 5~6 범위 확인: `rg -n "viewHomeFirstAudioTitle\.ivSectionTitleChevron\.setOnClickListener|viewContentNewAndHotTitle\.ivSectionTitleChevron\.setOnClickListener" app/src/main/java/kr/co/vividnext/sodalive/v2/main/home app/src/main/java/kr/co/vividnext/sodalive/v2/main/content` 결과 매치 없음. Phase 7 신규 전체보기 이동 라우팅은 아직 추가하지 않았다.
- 2026-06-27 Phase 5~6 코드 리뷰 확인: 문서 기준 발견 사항 2건을 확인했다. 1) 신규 API DTO 필드명이 PRD/계획서의 `items/contentId/coverImage`와 달리 `contents/audioContentId/imageUrl`로 구현되어 서버 계약 확인 또는 수정이 필요하다. 2) `contentId <= 0` item 상세 이동 무시 조건이 누락되어 수정이 필요하다.
- 2026-06-27 Phase 5~6 재검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"` 결과 PASS.
- 2026-06-27 Phase 5~6 재검증 실행: `./gradlew :app:mergeDebugResources`는 sandbox의 `~/.gradle` lock 접근 제한으로 1회 실패 후 권한 상승 재실행 결과 PASS.
- 2026-06-27 Phase 5~6 재검증 실행: `./gradlew :app:compileDebugKotlin` 결과 PASS.
- 2026-06-27 Phase 5~6 재검증 실행: `./gradlew :app:ktlintCheck` 결과 PASS.
- 2026-06-27 Phase 5~6 재검증 실행: `git diff --check` 결과 PASS(출력 없음).
- 2026-06-27 Phase 5~6 인접 회귀 재검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"` 결과 PASS.
- 2026-06-27 Phase 5~6 인접 회귀 재검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"` 결과 PASS.
- 2026-06-27 Phase 5~6 리뷰 후속 RED 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"` 결과 예상 실패. 테스트가 문서 계약 `items/contentId/coverImage`와 invalid `contentId <= 0` guard를 요구하지만 production이 아직 이전 DTO 계약과 guard 누락 상태였음을 확인했다.
- 2026-06-27 Phase 5~6 리뷰 후속 GREEN 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"` 결과 PASS.
- 2026-06-27 Phase 5~6 리뷰 후속 확인: `rg -n "totalCount|contents|audioContentId|imageUrl" "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview" "app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview"` 실행 결과 `audioContentId`, `imageUrl` 잔여 참조는 없고 `contents`는 API path/문자열 이름의 정상 매치만 남았다.
- 2026-06-27 Phase 5~6 리뷰 후속 재검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"` 결과 PASS.
- 2026-06-27 Phase 5~6 리뷰 후속 재검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"` 결과 PASS.
- 2026-06-27 Phase 5~6 리뷰 후속 재검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"` 결과 PASS.
- 2026-06-27 Phase 5~6 리뷰 후속 재검증 실행: `./gradlew :app:compileDebugKotlin` 결과 PASS.
- 2026-06-27 Phase 5~6 리뷰 후속 재검증 실행: `./gradlew :app:ktlintCheck`는 테스트 fixture 들여쓰기 문제로 1회 실패 후 수정, 재실행 결과 PASS. 기존 `.editorconfig``disabled_rules` deprecation 경고는 출력되었으나 실패 원인은 아니었다.
- 2026-06-27 Phase 5~6 리뷰 후속 재검증 실행: `git diff --check` 결과 PASS(출력 없음).
- 2026-06-27 Git 상태 확인: `app/src/main/res/drawable-mdpi/ic_new_arrow_up_gray.png`, `app/src/main/res/drawable-mdpi/ic_new_arrow_up_white.png``AD` 상태이며 현재 소스/문서에서 참조되지 않는다. 커밋 전 포함 여부 정리가 필요하다.
- 2026-06-27 Phase 5~6 코드 리뷰 재확인: `ContentOverviewActivity.renderLoading()`은 RecyclerView와 empty/error TextView를 숨기기만 하고, `activity_content_overview.xml`에도 loading/progress view가 없어 PRD의 첫 페이지 로딩/추가 페이지 로딩 상태 구분 요구가 사용자에게 표시되지 않는다. `ContentOverviewViewModel.isLoading`도 Activity에서 observe하지 않는다. 코드 수정이 필요하다.
- 2026-06-27 Phase 5~6 fresh 검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"` 결과 PASS.
- 2026-06-27 Phase 5~6 fresh 검증 실행: `./gradlew :app:compileDebugKotlin` 결과 PASS.
- 2026-06-27 Phase 5~6 fresh 검증 실행: `./gradlew :app:ktlintCheck` 결과 PASS.
- 2026-06-27 Phase 5~6 fresh 검증 실행: `git diff --check` 결과 PASS(출력 없음).
- 2026-06-27 Phase 5~6 fresh 검증 실행: `./gradlew :app:mergeDebugResources`는 sandbox의 `~/.gradle` wrapper lock 접근 제한으로 1회 실패 후 권한 상승 재실행 결과 PASS.
- 2026-06-27 Phase 5~6 인접 회귀 fresh 검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"` 결과 PASS.
- 2026-06-27 Phase 5~6 인접 회귀 fresh 검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"` 결과 PASS.
- 2026-06-27 Phase 5~6 loading UI 후속 작업 시작: PRD의 첫 페이지 로딩/추가 페이지 로딩 상태 구분 요구를 사용자에게 보이는 UI로 반영하기 위해 Task 6.6을 추가했다. Phase 7 라우팅과 legacy 파일 수정은 범위에서 제외한다.
- 2026-06-27 Phase 5~6 loading UI 후속 RED 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivitySourceTest"` 결과 예상 실패. layout progress id와 Activity 로딩 바인딩이 아직 없음을 확인했다.
- 2026-06-27 Phase 5~6 loading UI 후속 GREEN 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivitySourceTest"` 결과 PASS.
- 2026-06-27 Phase 5~6 loading UI 후속 검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"` 결과 PASS.
- 2026-06-27 Phase 5~6 loading UI 후속 검증 실행: `./gradlew :app:mergeDebugResources` 결과 PASS.
- 2026-06-27 Phase 5~6 loading UI 후속 검증 실행: `./gradlew :app:compileDebugKotlin` 결과 PASS.
- 2026-06-27 Phase 5~6 loading UI 후속 검증 실행: `./gradlew :app:ktlintCheck` 결과 PASS. 기존 `.editorconfig``disabled_rules` deprecation 경고는 출력되었으나 실패 원인은 아니었다.
- 2026-06-27 Phase 5~6 loading UI 후속 검증 실행: `git diff --check` 결과 PASS(출력 없음).
- 2026-06-27 Phase 5~6 코드 리뷰 및 검증 재확인: Phase 5 API/DTO/Repository/UI model과 Phase 6 ViewModel/Activity/Adapter 구현을 PRD/계획서 기준으로 재검토했고 추가 코드 수정이 필요한 발견 사항은 없었다. `Task 6.6`은 코드와 검증 기록이 완료 상태라 체크박스를 완료로 갱신했다.
- 2026-06-27 Phase 5~6 재검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"` 결과 PASS.
- 2026-06-27 Phase 5~6 재검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"` 결과 PASS.
- 2026-06-27 Phase 5~6 재검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"` 결과 PASS.
- 2026-06-27 Phase 5~6 재검증 실행: `./gradlew :app:mergeDebugResources`는 sandbox의 `~/.gradle` wrapper lock 접근 제한으로 1회 실패 후 권한 상승 재실행 결과 PASS.
- 2026-06-27 Phase 5~6 재검증 실행: `./gradlew :app:compileDebugKotlin` 결과 PASS.
- 2026-06-27 Phase 5~6 재검증 실행: `./gradlew :app:ktlintCheck` 결과 PASS.
- 2026-06-27 Phase 5~6 재검증 실행: `git diff --check` 결과 PASS(출력 없음).
- 2026-06-27 Phase 7 범위 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivitySourceTest"` 결과 PASS.
- 2026-06-27 Phase 7 범위 실행: `./gradlew :app:compileDebugKotlin` 결과 PASS.
- 2026-06-27 Phase 7 범위 실행: `./gradlew :app:ktlintCheck``HomeMainFragmentSourceTest`의 긴 assertion 줄로 1회 실패 후 포맷 수정, 재실행 결과 PASS. 기존 `.editorconfig``disabled_rules` deprecation 경고는 출력되었으나 실패 원인은 아니었다.
- 2026-06-27 Phase 7 범위 실행: `git diff --check` 결과 PASS(출력 없음).
- 2026-06-27 Phase 7 수동 화면 검증: 현재 작업 환경에서 Android 기기 또는 에뮬레이터를 통한 실제 화면 조작을 수행할 수 없어 홈 `처음부터 함께 성장!` 및 콘텐츠 `New&Hot` chevron 클릭 후 신규 전체보기 화면 진입은 실제 앱 화면에서 확인하지 못했다. source 테스트와 compile/ktlint로 라우팅 계약은 검증했다.
- 2026-06-27 Phase 7 리뷰 게이트 결과: source 테스트가 신규 overview 라우팅의 `ensureMainV2NavigationAllowed` guard 경유를 충분히 고정하지 못한다는 지적으로 1회 Reject를 받았다. production 코드는 유지하고 홈/콘텐츠 source 테스트의 guard 내부 순서 검증을 보강했다.
- 2026-06-27 Phase 7 리뷰 후속 재검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivitySourceTest"` 결과 PASS.
- 2026-06-27 Phase 7 리뷰 후속 재검증 실행: `./gradlew :app:compileDebugKotlin` 결과 PASS.
- 2026-06-27 Phase 7 리뷰 후속 재검증 실행: `./gradlew :app:ktlintCheck` 결과 PASS. 기존 `.editorconfig``disabled_rules` deprecation 경고는 출력되었으나 실패 원인은 아니었다.
- 2026-06-27 Phase 7 리뷰 후속 재검증 실행: `git diff --check` 결과 PASS(출력 없음).
- 2026-06-27 Phase 7 코드 리뷰 및 점검: 홈 `처음부터 함께 성장!`과 콘텐츠 `New&Hot` chevron이 각각 `ContentOverviewType.FIRST_AUDIO_CONTENT`, `ContentOverviewType.NEW_AND_HOT_AUDIO``ContentOverviewActivity.newIntent(...)`를 호출하고, 두 경로 모두 `ensureMainV2NavigationAllowed` guard를 먼저 경유하는 것을 확인했다. 추가 코드 수정이 필요한 발견 사항은 없었다.
- 2026-06-27 Phase 7 코드 리뷰 및 점검 재검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.overview.ContentOverviewActivitySourceTest"` 결과 PASS.
- 2026-06-27 Phase 7 코드 리뷰 및 점검 재검증 실행: `./gradlew :app:compileDebugKotlin` 결과 PASS.
- 2026-06-27 Phase 7 코드 리뷰 및 점검 재검증 실행: `./gradlew :app:ktlintCheck` 결과 PASS.
- 2026-06-27 Phase 7 코드 리뷰 및 점검 재검증 실행: `git diff --check` 결과 PASS(출력 없음).
- 2026-06-27 Phase 8 통합 검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"` 결과 PASS.
- 2026-06-27 Phase 8 통합 검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"` 결과 PASS.
- 2026-06-27 Phase 8 통합 검증 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*"` 결과 PASS.
- 2026-06-27 Phase 8 빌드/리소스/린트 검증 실행: `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` 결과 모두 PASS.
- 2026-06-27 Phase 8 수동 화면 검증: Android 기기/에뮬레이터를 사용할 수 없어 실제 화면 조작 검증은 BLOCKED로 남겼다. 자동 테스트와 빌드 검증은 모두 통과했다.
- 2026-06-29: 홈 추천 탭 `처음부터 함께 성장!` 섹션 제거에 따라 `FIRST_AUDIO_CONTENT` 신규 전체보기 진입을 제거했다. 회귀 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*" --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*" :app:mergeDebugResources :app:compileDebugKotlin :app:ktlintCheck`가 BUILD SUCCESSFUL임을 확인했고, `git diff --check` 출력 없음과 `app/src/main` production 참조 제거를 확인했다. Android 기기/에뮬레이터 접근이 없어 실제 화면 조작은 수행하지 못했다.
## 2026-06-29 변경: 홈 `처음부터 함께 성장!` 제거 반영
- 홈 추천 탭 `처음부터 함께 성장!` 섹션 제거에 따라 `FIRST_AUDIO_CONTENT` 신규 전체보기 진입도 제거한다.
- 현재 신규 전체보기 유지 대상은 콘텐츠 추천 탭 `New&Hot` -> `NEW_AND_HOT_AUDIO`뿐이다.
- 기존 Phase별 완료 로그는 당시 검증 이력으로 유지하며, 후속 구현은 이 변경 항목을 현재 계약으로 우선한다.

View File

@@ -0,0 +1,243 @@
# PRD: 콘텐츠 전체보기
## 1. Overview
콘텐츠 추천 탭의 일부 섹션에서 `전체보기` 진입을 제공하고, `New&Hot`은 신규 콘텐츠 전체보기 화면에서 `GET /api/v2/contents` API로 페이징 목록을 표시한다.
작성일: 2026-06-27
---
## 2. Problem
- 콘텐츠 추천 탭의 섹션 타이틀 우측 chevron이 현재 전체보기 이동 정책과 연결되어 있지 않다.
- `New&Hot`은 기존 콘텐츠 탭 내부 `전체` 탭의 카테고리/정렬 조합으로 표현하지 않고, 별도 API 기반 전체보기 화면이 필요하다.
- 그 외 콘텐츠 추천 섹션은 신규 화면을 만들지 않고 기존 `콘텐츠 탭 - 전체`의 특정 카테고리/정렬 상태로 이동해야 한다.
- 동일한 오디오 카드, 태그, 페이징 목록 UI가 이미 V2 패키지 하위에 있으므로 신규 UI를 중복 작성하지 않도록 재사용 후보를 먼저 정리해야 한다.
---
## 3. Goals
- 콘텐츠 추천 탭에서 전체보기가 필요한 섹션 제목 우측에 chevron을 표시한다.
- 콘텐츠 추천 탭의 `New&Hot` 섹션은 신규 콘텐츠 전체보기 화면으로 이동한다.
- 콘텐츠 추천 탭의 `오직 보이스온에서만!`, `새로 올라온 오디오`, `무료 오디오`, `포인트 오디오`는 기존 `콘텐츠 탭 - 전체`의 지정 상태로 이동한다.
- 신규 콘텐츠 전체보기 화면은 `type = NEW_AND_HOT_AUDIO``GET /api/v2/contents`를 호출한다.
- 신규 콘텐츠 전체보기 화면은 Figma node `482:15105``detail_ado_001` 구조를 기준으로 검은 배경, title bar, 2열 오디오 카드 그리드, 스크롤 페이징을 제공한다.
- V2 패키지 하위 기존 위젯 중 재사용 가능한 후보를 문서에 기록한다.
---
## 4. Non-Goals
- 이번 PRD 작성 단계에서는 코드, 리소스, 레이아웃 파일을 구현하지 않는다.
- 콘텐츠 상세, 시리즈 상세, 결제, 보관함 기능은 변경하지 않는다.
- 레거시 화면 또는 레거시 API 파일을 직접 수정하지 않는다.
- `GET /api/v2/audio/contents` API 계약은 변경하지 않는다.
- 신규 콘텐츠 전체보기 화면에 별도 정렬, 필터, 검색, pull-to-refresh, skeleton loading을 추가하지 않는다.
- `New&Hot` 외 섹션을 신규 전체보기 API로 조회하지 않는다.
- Figma localhost asset URL을 앱 코드에 직접 의존하지 않는다.
---
## 5. Target Users
- 콘텐츠 추천 탭에서 `New&Hot`, 최신 오디오, 무료/포인트 오디오, 오리지널 콘텐츠를 섹션별로 더 보고 싶은 사용자.
- V2 메인 홈/콘텐츠 화면과 신규 전체보기 화면을 구현/유지보수하는 Android 개발자.
---
## 6. User Stories
- 사용자는 콘텐츠 추천 탭의 `New&Hot` 섹션 제목 우측 chevron을 눌러 `New&Hot` 전체 목록을 보고 싶다.
- 사용자는 콘텐츠 추천 탭의 `오직 보이스온에서만!`을 누르면 콘텐츠 탭의 `전체` 내부에서 오리지널 카테고리가 선택된 화면으로 이동하길 기대한다.
- 사용자는 콘텐츠 추천 탭의 `새로 올라온 오디오`를 누르면 콘텐츠 탭의 `전체` 내부에서 오디오 카테고리가 선택된 화면으로 이동하길 기대한다.
- 사용자는 콘텐츠 추천 탭의 `무료 오디오` 또는 `포인트 오디오`를 누르면 콘텐츠 탭의 `전체` 내부에서 해당 카테고리와 인기순 정렬이 선택된 화면으로 이동하길 기대한다.
- 사용자는 신규 전체보기 화면에서 콘텐츠를 2열 그리드로 탐색하고, 목록 하단에 도달하면 다음 페이지가 이어서 로드되길 기대한다.
---
## 7. Core Features
### Feature A. 전체보기 섹션 chevron 표시
#### Requirements
- 전체보기 진입이 필요한 섹션 제목 우측에는 `view_section_title.xml``iv_section_title_chevron`을 표시한다.
- 콘텐츠 추천 탭에서는 아래 섹션에 chevron을 표시한다.
- `오직 보이스온에서만!`
- `새로 올라온 오디오`
- `New&Hot`
- `무료 오디오`
- `포인트 오디오`
- 콘텐츠 추천 탭의 `댓글 많은 오디오`, `추천 오디오`는 이번 요구사항에 전체보기 목적지가 없으므로 chevron을 표시하지 않는다.
- 섹션 데이터가 비어 섹션 자체가 숨겨지는 경우 chevron도 함께 노출되지 않는다.
#### Edge Cases
- 빠르게 chevron을 중복 탭해도 동일 화면이 중복으로 여러 개 쌓이지 않도록 기존 navigation guard 패턴을 우선 따른다.
- 전체보기 목적지에 필요한 enum 또는 tab 상태가 유효하지 않으면 이동하지 않는다.
### Feature B. 콘텐츠 추천 탭 전체보기 라우팅
#### Requirements
- 콘텐츠 추천 탭의 전체보기 이동 규칙은 아래와 같다.
| 섹션 | 이동 목적지 |
| --- | --- |
| `오직 보이스온에서만!` | `콘텐츠 탭 - 전체` -> `오리지널` 카테고리 선택 |
| `새로 올라온 오디오` | `콘텐츠 탭 - 전체` -> `오디오` 카테고리 선택 |
| `New&Hot` | 신규 콘텐츠 전체보기 화면 -> `type = NEW_AND_HOT_AUDIO` |
| `무료 오디오` | `콘텐츠 탭 - 전체` -> `무료` 카테고리 선택 -> `인기순` 정렬 |
| `포인트 오디오` | `콘텐츠 탭 - 전체` -> `포인트` 카테고리 선택 -> `인기순` 정렬 |
- 기존 `콘텐츠 탭 - 전체`로 이동하는 경우 `ContentMainFragment` 내부 탭은 `전체`가 선택되어야 한다.
- `무료 오디오`, `포인트 오디오``ContentSort.POPULAR`에 해당하는 정렬 상태로 진입한다.
- `오직 보이스온에서만!``오리지널` 카테고리로 진입한다.
#### Edge Cases
- 이미 콘텐츠 탭에 있는 상태에서 전체보기 이동을 누르면 새 메인 화면을 중복 생성하지 않고 현재 `ContentMainFragment`의 내부 상태 전환을 우선 검토한다.
- 홈 탭에서 콘텐츠 탭 내부 `전체`로 이동해야 하는 후속 요구가 생기면 `MainActivity`/`MainV2` 탭 전환 계약을 별도 계획에서 확인한다.
### Feature C. 신규 콘텐츠 전체보기 화면
#### Requirements
- 신규 화면은 기존 로직 수정이 아닌 신규 `Activity`, `ViewModel`, API, Repository, DTO, adapter/helper로 구현한다면 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
- 화면 title bar 제목은 진입 type에 따라 아래처럼 표시한다.
- `NEW_AND_HOT_AUDIO`: `New&Hot`
- title bar는 검은 배경, 좌측 back chevron, 22sp bold 제목 구조를 따른다.
- 콘텐츠 목록은 Figma node `482:15105` 기준으로 2열 오디오 카드 그리드로 표시한다.
- 카드에는 썸네일, 제목, 크리에이터 닉네임, 무료/포인트/FIRST/오리지널/성인 태그를 응답 값에 따라 표시한다.
- 카드 터치 시 기존 오디오 콘텐츠 상세 화면으로 이동한다.
- 목록은 첫 페이지 로딩, 빈 목록, 에러, 추가 페이지 로딩 상태를 구분한다.
- `hasNext = true`이고 사용자가 목록 하단에 접근하면 다음 `page`를 요청한다.
#### Figma Reference
- URL: `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-공유용-보이스온-UI-UX-기획문서?node-id=482-15105&m=dev`
- node: `482:15105`
- frame name: `detail_ado_001`
- 확인된 구조:
- 화면 배경: black
- title bar height: 60
- title: `New&Hot`
- content start: title bar 하단 이후
- grid: 2열, 카드 폭 약 185, 카드 간격 약 4
- thumbnail: 정사각형, radius 14
- label: 제목 18sp bold, 크리에이터명 14sp medium, 한 줄 말줄임
- tags: FIRST, point, free, original audio, adult badge 조합
#### Edge Cases
- `contentId <= 0`인 item은 상세 이동을 무시한다.
- `coverImage`가 null 또는 blank이면 기존 이미지 로딩 placeholder/null 처리 정책을 따른다.
- 제목 또는 크리에이터명이 길면 한 줄 말줄임 처리한다.
- 첫 페이지 응답의 `items`가 비어 있으면 빈 목록 상태를 표시한다.
- 추가 페이지 실패 시 기존 목록은 유지하고 재시도 가능한 상태를 제공한다.
### Feature D. 신규 콘텐츠 전체보기 API
#### API Contract
```kotlin
GET /api/v2/contents
```
#### Query Parameters
```kotlin
page: Int = 0
size: Int = 20
type: ContentOverviewType = ContentOverviewType.NEW_AND_HOT_AUDIO
```
#### Response Data Class
```kotlin
data class ContentOverviewPageResponse(
val type: ContentOverviewType,
val items: List<ContentOverviewItemResponse>,
val page: Int,
val size: Int,
@SerializedName("hasNext")
val hasNext: Boolean
)
enum class ContentOverviewType {
NEW_AND_HOT_AUDIO
}
data class ContentOverviewItemResponse(
val contentId: Long,
val title: String,
val coverImage: String?,
val price: Int,
@SerializedName("isPointAvailable")
val isPointAvailable: Boolean,
val creatorNickname: String,
@SerializedName("isAdult")
val isAdult: Boolean,
@SerializedName("isFirstContent")
val isFirstContent: Boolean,
@SerializedName("isOriginalSeries")
val isOriginalSeries: Boolean
)
```
#### Requirements
- API 기본값은 `page = 0`, `size = 20`, `type = NEW_AND_HOT_AUDIO`로 취급한다.
- query parameter key는 모두 소문자 `page`, `size`, `type`을 사용한다.
- 앱에서는 진입 목적에 맞게 `type`을 명시적으로 전달한다.
- 응답 DTO는 서버 계약을 변경하지 않는다.
- 서버 예시 class에 Jackson `@JsonProperty`가 포함되어 있더라도 앱 구현에서는 기존 Gson 관례에 맞춰 `@SerializedName`을 사용한다.
- UI model에서는 `price == 0`이면 무료 태그, `isPointAvailable == true`이면 포인트 태그, `isFirstContent == true`이면 FIRST 태그, `isOriginalSeries == true`이면 오리지널 태그, `isAdult == true`이면 성인 배지로 매핑한다.
#### Edge Cases
- 응답 `type`이 요청 `type`과 다르면 현재 요청 type 기준으로 화면 제목을 유지하고, 데이터 혼입 방지 정책은 구현 계획에서 확정한다.
- `hasNext = false`이면 다음 페이지를 요청하지 않는다.
- 동일 type에서 추가 페이지 요청 중 중복 요청을 방지한다.
- 다른 type의 신규 전체보기 화면을 열 때는 이전 화면의 page/items 상태를 공유하지 않는다.
---
## 8. UX / UI Expectations
- 신규 전체보기 화면은 V2의 검은 배경과 콘텐츠 카드 스타일을 유지한다.
- 상단 title bar는 스크롤되지 않고, 목록만 세로 스크롤된다.
- 2열 그리드는 화면 폭에 맞춰 item width를 계산하되, Figma의 185px 카드와 4px 간격 비율을 Android 화면에서 자연스럽게 유지한다.
- 오디오 카드는 기존 `AudioContentCardView`의 태그 표현과 최대한 일치시킨다.
- 성인 배지는 썸네일 우측 상단에 표시한다.
- 무료/포인트 태그는 썸네일 하단 좌측, FIRST/오리지널 태그는 썸네일 상단 좌측의 기존 패턴을 우선 따른다.
- 홈 추천 탭과 콘텐츠 추천 탭의 섹션 chevron은 기존 `view_section_title.xml``ic_chevron_right`를 사용한다.
### 재사용 가능한 V2 위젯/코드 후보
- `app/src/main/res/layout/view_section_title.xml`
- 섹션 제목과 우측 chevron 표시/숨김에 재사용 가능하다.
- `kr.co.vividnext.sodalive.v2.widget.AudioContentCardView`
- 신규 전체보기 2열 오디오 카드의 기본 카드 UI 후보이다.
- `kr.co.vividnext.sodalive.v2.widget.AudioContentCardSize`
- 기존 카드 크기 variant를 확인해 신규 2열 grid width 적용 가능성을 검토한다.
- `kr.co.vividnext.sodalive.v2.widget.AudioContentTag`
- 무료/포인트/FIRST/오리지널 태그 매핑에 재사용 가능하다.
- `kr.co.vividnext.sodalive.v2.main.content.ui.ContentAllAudioCardAdapter`
- 기존 콘텐츠 `전체` 탭 3열 grid adapter이며, 동적 grid item width 적용 패턴을 참고할 수 있다.
- `kr.co.vividnext.sodalive.v2.main.content.ui.ContentAudioCardAdapter`
- 추천 탭의 가로 오디오 카드 바인딩 패턴을 참고할 수 있다.
- `kr.co.vividnext.sodalive.v2.main.content.ui.ContentNewAndHotAdapter`
- `New&Hot` 섹션의 리스트형 표현에는 이미 사용 중이지만, 신규 전체보기 2열 grid에는 직접 재사용보다 item mapping/tag binding 참고 후보이다.
- `kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModel`
- page/size/hasNext 기반 페이징 상태 관리 패턴 참고 후보이다.
- `kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllTabApi`
- V2 콘텐츠 API의 Retrofit/Rx/`ApiResponse` 계약 참고 후보이다.
---
## 9. Technical Constraints
- Android Gradle 단일 `:app` 모듈에서 작업한다.
- 모든 명령은 저장소 루트에서 실행한다.
- 신규 화면/하위 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
- 레거시 파일은 직접 수정하지 않고, 필요한 기존 화면은 Intent 또는 wrapper/adapter로 호출한다.
- API 흐름은 기존 관례인 `Api -> Repository -> ViewModel -> Activity/Fragment`를 따른다.
- DI 추가가 필요하면 `AppDI.kt`의 Koin 구성 관례를 따른다.
- 외부 라이브러리를 추가하지 않는다.
- 공개 API 스키마와 서버 enum 값을 임의 변경하지 않는다.
- `BuildConfig` 값이나 민감정보를 로그/Toast/크래시 메시지에 노출하지 않는다.
---
## 10. Metrics
- 콘텐츠 추천 탭 섹션별 chevron 클릭 수.
- 신규 콘텐츠 전체보기 화면 진입 수.
- 신규 콘텐츠 전체보기 화면의 다음 페이지 로드 성공/실패 수.
- 신규 콘텐츠 전체보기 화면에서 콘텐츠 상세로 이동한 클릭 수.
---
## 11. Open Questions
- 기존 `ContentMainFragment` 상태를 외부에서 특정 내부 탭/카테고리/정렬로 열기 위한 public navigation contract가 충분한지 구현 계획에서 확인해야 한다.
### 2026-06-29 변경: 홈 `처음부터 함께 성장!` 전체보기 제거
- 홈 추천 탭의 `처음부터 함께 성장!` 섹션 제거에 따라 홈에서 `ContentOverviewType.FIRST_AUDIO_CONTENT`로 진입하는 경로를 제거한다.
- 신규 콘텐츠 전체보기 화면의 현재 진입 type은 콘텐츠 추천 탭 `New&Hot``NEW_AND_HOT_AUDIO`만 유지한다.

View File

@@ -39,6 +39,8 @@
- Kotlin 테스트 메서드는 backtick 함수명을 사용하고, 테스트 의도가 드러나도록 한글 문장으로 작성한다.
- 기존 영어 테스트명을 수정하지 않는 최소 변경 상황에서는 해당 테스트가 무엇을 검증하는지 한글 주석을 추가한다.
- 테스트 추가 시 단일 실행 명령 예시도 `docs/agent-guides/build-test-style.md`에 갱신한다.
- 신규 테스트는 로직에 대한 테스트 코드만 작성한다. View 크기, margin, padding, constraint, visibility 같은 UI 레이아웃/표현 속성 검증 테스트는 작성하지 않는다.
- UI 작업에서 테스트가 필요하면 adapter view type 선택, rank 구간 분류, mapper, formatter, presentation model, visibility를 결정하는 순수 로직처럼 화면 표현을 결정하는 입력/출력 계약만 검증한다.
### 6) 주석
- 의미 단위별로 주석을 작성한다.

View File

@@ -656,9 +656,52 @@ Expected: `BUILD SUCCESSFUL`
Expected:
- 실행한 명령, 결과, 실패 시 원인과 후속 조치를 이 문서 하단 `검증 기록`에 누적한다.
### Task 8: Horizontal rank=20 표시 보정
**Files:**
- Modify: `docs/prd/20260520_콘텐츠랭킹위젯컴포넌트_prd.md`
- Modify: `docs/plan-task/20260520_콘텐츠랭킹위젯컴포넌트.md`
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingCardViewTest.kt`
- Modify: `app/src/main/res/layout/view_content_ranking_horizontal_card.xml`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt`
- [x] **Step 1: 원인 확인과 문서 반영**
Expected:
- `ContentRankingHorizontalCardView`의 rank group과 rank `TextView`가 고정 폭/높이를 받아 `20` 표시가 잘릴 수 있음을 기록한다.
- Horizontal rank group과 rank `TextView``wrap_content` 폭을 유지하도록 계약을 갱신한다.
- [x] **Step 2: RED - rank=20 wrap_content 테스트 갱신**
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingCardViewTest"`
Expected: production 수정 전 rank group/rank `TextView` 고정 폭 assertion 때문에 실패한다.
Result: production 수정 전 `ContentRankingCardViewTest`를 실행해 Horizontal rank group fixed width assertion 실패를 확인했다.
- [x] **Step 3: GREEN - Horizontal rank 고정 폭 제거**
Expected:
- `view_content_ranking_horizontal_card.xml``ll_content_ranking_rank_group` width를 `wrap_content`로 유지한다.
- `ContentRankingHorizontalCardView.positionViews()`에서 rank group width와 rank `TextView` width/height 고정 설정을 제거한다.
- 이미지/텍스트 영역, API/ViewModel/Adapter 동작은 변경하지 않는다.
Result: `view_content_ranking_horizontal_card.xml`의 rank group width를 `wrap_content`로 바꾸고, `ContentRankingHorizontalCardView.positionViews()`에서 rank group/rank `TextView` 고정 width/height와 rank padding 설정을 제거했다. `ContentRankingCardViewTest``BUILD SUCCESSFUL`로 통과했다.
- [x] **Step 4: 검증 기록 누적**
Run:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingCardViewTest"`
- `./gradlew :app:mergeDebugResources`
- `./gradlew :app:compileDebugKotlin`
Result: 세 명령이 모두 `BUILD SUCCESSFUL`로 통과했다. 병렬 Gradle 실행 중 Kotlin incremental cache 충돌이 있었으나 `./gradlew --stop` 후 순차 재실행으로 통과했다.
---
## 검증 기록
- 2026-06-29: 후속 요구사항으로 `ContentRankingHorizontalCardView`에서 `20` 순위가 잘리는 문제를 분석했다. `view_content_ranking_horizontal_card.xml`의 rank group 49dp, `ContentRankingHorizontalCardView.positionViews()`의 rank group `49 * scale` 및 rank `TextView` `48 * scale x 52 * scale` 고정 크기가 원인일 수 있음을 확인했고, 구현 전 PRD와 계획 문서에 Horizontal rank group/rank `TextView` `wrap_content` 계약을 반영했다.
- 2026-06-29: `ContentRankingCardViewTest`를 갱신해 Horizontal `rank=20`, rank group `WRAP_CONTENT`, rank `TextView` `WRAP_CONTENT`를 검증하도록 했다. production 수정 전에는 rank group fixed width assertion이 실패했고, `ContentRankingHorizontalCardView``view_content_ranking_horizontal_card.xml` 보정 후 동일 테스트가 `BUILD SUCCESSFUL`로 통과했다. `mergeDebugResources`, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`도 통과했다.
- 2026-05-20: 문서만 먼저 작성하는 요청이므로 구현/빌드/테스트는 실행하지 않았다. Figma `20:3715`, `20:3718`, `20:3721`, `20:3724`의 design context와 screenshot을 확인해 PRD 및 구현 계획에 반영했다.
- 2026-05-20: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingItemTest" --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingDeltaPresentationTest" --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"`를 먼저 실행해 `RankingChangeType` 및 콘텐츠 랭킹 contract 미구현으로 실패하는 RED를 확인했다.
- 2026-05-20: 공용 `RankingChangeType`, 콘텐츠 랭킹 placement/item/delta/layout calculator, XML/custom view/adapter를 구현한 뒤 동일 단위 테스트 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -77,6 +77,7 @@ Figma `20:3715`, `20:3718`, `20:3721`, `20:3724` 디자인을 기준으로 콘
- `MediumGrid`: 2위~7위 전용 정사각형 카드다. 2열 배치를 기준으로 콘텐츠명은 `22sp` bold 스타일을 사용한다.
- `SmallGrid`: 8위~10위 전용 정사각형 카드다. 3열 배치를 기준으로 콘텐츠명은 `14sp` bold 스타일을 사용한다.
- `Horizontal`: 11위 이후 전용 가로형 카드다. 좌측 순위/변동, 중앙 이미지, 우측 콘텐츠명/크리에이터명 영역을 가진다.
- `Horizontal`의 순위 숫자와 rank group은 두 자리 순위(`20` 등)가 잘리지 않도록 고정 폭이 아닌 `wrap_content`를 유지한다.
- Figma metadata size는 참고용 비율 확인에만 사용하고, 구현에서 고정 dp 크기로 사용하지 않는다.
#### Text Requirements
@@ -154,6 +155,7 @@ Figma `20:3715`, `20:3718`, `20:3721`, `20:3724` 디자인을 기준으로 콘
- 1위 카드는 배경 영역과 중앙 콘텐츠 이미지가 분리된 형태를 유지한다.
- 2위~7위와 8위~10위는 각각 2열/3열 배치에 맞춰 같은 데이터 계약을 다른 variant로 표시한다.
- 11위 이후 카드는 좌측 순위, 중앙 이미지, 우측 텍스트 영역을 가진다.
- 11위 이후 카드의 순위 숫자와 이를 감싸는 rank group은 `wrap_content` 폭을 유지해 `20` 같은 두 자리 순위가 잘리지 않아야 한다.
- 이미지 크기는 고정 dp로 박지 않고 row container 폭에서 계산한다.
- 정사각형 variant는 계산된 카드 폭과 동일한 높이로 표시한다.
- 가로형 variant는 부모 폭을 채우고 Figma 가로형 비율에 맞는 높이를 유지한다.
@@ -183,11 +185,16 @@ Figma `20:3715`, `20:3718`, `20:3721`, `20:3724` 디자인을 기준으로 콘
- 차단 관계 상태에서 이미지 블러, 이름 비노출/대체문구, 터치 불가가 모두 적용된다.
- 차단 관계 상태에서 1위~10위 카드의 gradient overlay가 유지된다.
- 이미지 크기가 고정 dp가 아닌 부모 폭과 row count 기반으로 계산된다.
- 11위 이후 Horizontal variant에서 `rank=20`도 전체 숫자가 표시된다.
- 관련 unit test와 Android resource merge/build가 성공한다.
## 11. Verification Log
- 2026-06-29: 후속 요구사항으로 `ContentRankingHorizontalCardView`에서 `20` 순위가 잘리는 문제를 확인했다. 원인은 XML `ll_content_ranking_rank_group` 49dp와 Kotlin의 rank group `49 * scale`, rank `TextView` `48 * scale x 52 * scale` 고정 크기 설정으로 정리했고, Horizontal rank group/rank `TextView``wrap_content`로 유지하는 요구사항을 반영했다.
- 2026-06-29: Horizontal 보정으로 `ll_content_ranking_rank_group``tv_content_ranking_rank`가 모두 `WRAP_CONTENT` 폭/높이를 유지하도록 변경했다. `ContentRankingCardViewTest`에서 `rank=20` 회귀 테스트를 추가했고, `ContentRankingCardViewTest`, `mergeDebugResources`, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`가 통과했다.
---
## 11. Open Questions
## 12. Open Questions
- 서버 응답에 이전 순위, 신규 진입 여부, 차단 관계 여부가 이미 포함되는지 확인이 필요하다. 없으면 API/DTO 확장이 별도 백엔드 협의 항목이다.
- Figma `get_design_context` 확인 결과 typography/color/radius 토큰은 본 문서의 `Figma Token Requirements`에 반영했다.
- 콘텐츠명 글자 수 제한은 사용자 요구사항에 따라 1위 16자, 2위~10위 8자, 11위 이후 12자로 확정한다.