Compare commits
45 Commits
1e073e85a1
...
926e7eabd3
| Author | SHA1 | Date | |
|---|---|---|---|
| 926e7eabd3 | |||
| 7c7aa0f442 | |||
| 86be8e4af1 | |||
| d5cc5df704 | |||
| aac09c14bc | |||
| 566f76df6a | |||
| 3ce5cd0e3b | |||
| f3f175fcc6 | |||
| 16995e5a0f | |||
| 43c8762df7 | |||
| f27668e038 | |||
| 3e14cf4320 | |||
| 78573d0e81 | |||
| bc5814b7c5 | |||
| ed2e478291 | |||
| 3ba46916b1 | |||
| 9d2ccd975f | |||
| acbf9bb013 | |||
| 9fce374842 | |||
| 08c7bcf4f2 | |||
| 93a5d1293c | |||
| 5578d59e1f | |||
| f1e3b5a49e | |||
| b9d474075f | |||
| 37200773aa | |||
| bbe9eca97b | |||
| 77a201e139 | |||
| 4c7887055d | |||
| 0d7512fee2 | |||
| 0da2a3f85f | |||
| 9ab5101c48 | |||
| 0906a21c6d | |||
| 0134942172 | |||
| 0a4b88fabe | |||
| b772e5416e | |||
| 1416db105e | |||
| b6cc37fe50 | |||
| 43e90ed47b | |||
| 5099795d29 | |||
| 73dc939cd3 | |||
| 1bd0f369ee | |||
| 8da39949e5 | |||
| 0454980fae | |||
| 0679dc7deb | |||
| 0e07ed0b5a |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
enum class CreatorRankingCardVariant {
|
||||
Large,
|
||||
Compact,
|
||||
Horizontal
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||
|
||||
enum class CreatorRankingViewType {
|
||||
TopTen,
|
||||
Lower
|
||||
}
|
||||
96
app/src/main/res/layout/activity_content_overview.xml
Normal file
96
app/src/main/res/layout/activity_content_overview.xml
Normal 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&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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
96
app/src/main/res/layout/view_creator_ranking_top_card.xml
Normal file
96
app/src/main/res/layout/view_creator_ranking_top_card.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 참조 제거를 확인했다. 리뷰 게이트에서 차단 이슈 없이 승인받았다.
|
||||
|
||||
@@ -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`)의 노출/매핑 계약은 유지한다.
|
||||
|
||||
@@ -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 테스트 정책을 계획 문서에 반영했다.
|
||||
|
||||
@@ -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 중심으로 제한한다고 기록했다.
|
||||
|
||||
@@ -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했다.
|
||||
|
||||
@@ -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 문서 보완만 수행했으며 구현/빌드/테스트는 실행하지 않았다.
|
||||
|
||||
77
docs/20260627_release_서명_정보_입력_구성/plan-task.md
Normal file
77
docs/20260627_release_서명_정보_입력_구성/plan-task.md
Normal 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로 완료됐다.
|
||||
59
docs/20260627_release_서명_정보_입력_구성/prd.md
Normal file
59
docs/20260627_release_서명_정보_입력_구성/prd.md
Normal 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`에 입력하는 설계로 진행한다.
|
||||
712
docs/20260627_콘텐츠_전체보기/plan-task.md
Normal file
712
docs/20260627_콘텐츠_전체보기/plan-task.md
Normal 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별 완료 로그는 당시 검증 이력으로 유지하며, 후속 구현은 이 변경 항목을 현재 계약으로 우선한다.
|
||||
243
docs/20260627_콘텐츠_전체보기/prd.md
Normal file
243
docs/20260627_콘텐츠_전체보기/prd.md
Normal 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`만 유지한다.
|
||||
@@ -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) 주석
|
||||
- 의미 단위별로 주석을 작성한다.
|
||||
|
||||
@@ -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`을 확인했다.
|
||||
|
||||
@@ -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자로 확정한다.
|
||||
|
||||
Reference in New Issue
Block a user