docs(content): 콘텐츠 랭킹 탭 계획을 추가한다
This commit is contained in:
634
docs/20260623_메인_콘텐츠_탭_내부_랭킹_탭/plan-task.md
Normal file
634
docs/20260623_메인_콘텐츠_탭_내부_랭킹_탭/plan-task.md
Normal file
@@ -0,0 +1,634 @@
|
||||
# 메인 콘텐츠 탭 내부 랭킹 탭 구현 계획/TASK
|
||||
|
||||
> **For agentic workers:** 구현 시 `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`를 사용해 task 단위로 진행한다. 각 단계는 체크박스(`- [ ]`)로 추적하고, 완료 즉시 `- [x]`로 갱신한다. 구현 범위 변경이 생기면 이 문서를 먼저 수정한 뒤 코드에 반영한다.
|
||||
|
||||
**Goal:** `GET /api/v2/audio/rankings?type={AudioRankingType}` 응답을 기반으로 메인 콘텐츠 탭 내부 `랭킹` 탭에 오디오 콘텐츠 랭킹 목록을 표시한다.
|
||||
|
||||
**Architecture:** 기존 `ContentMainFragment`와 `fragment_v2_main_content.xml`을 최소 확장해 `추천`, `랭킹`, `전체` Text Tab bar와 랭킹 전용 Capsule Tab bar/RecyclerView를 연결한다. 신규 API/Repository/DTO/UI state/mapper/ViewModel은 `kr.co.vividnext.sodalive.v2.main.content` 하위에 둔다. 기존 `kr.co.vividnext.sodalive.v2.widget.contentranking` 위젯은 `showRankChange=false`일 때 rank-num `GONE` 처리만 필요한 만큼 확장한다.
|
||||
|
||||
**Tech Stack:** Kotlin, Android XML Views, ViewBinding, RecyclerView, Retrofit, Gson, RxJava3, Koin, JUnit4/Robolectric local unit test.
|
||||
|
||||
---
|
||||
|
||||
## 전제와 성공 기준
|
||||
- PRD: `docs/20260623_메인_콘텐츠_탭_내부_랭킹_탭/prd.md`
|
||||
- Figma 전체 화면: `cont_002` `24:6857`
|
||||
- API endpoint는 `GET /api/v2/audio/rankings`이고 선택 타입은 `type` query parameter로 전달한다.
|
||||
- 예: `GET /api/v2/audio/rankings?type=WEEKLY_POPULAR`
|
||||
- 내부 Text Tab bar 항목은 `추천`, `랭킹`, `전체` 순서다.
|
||||
- Text Tab bar 영역의 `전체` 탭은 항목만 표시하고, 실제 content 화면/API 연동은 이번 범위에서 제외한다.
|
||||
- 랭킹 유형 Capsule Tab bar 항목은 `주간 인기`, `지금 뜨는 중`, `매출`, `판매량`, `댓글수`, `좋아요` 순서다.
|
||||
- 초기 랭킹 유형은 `WEEKLY_POPULAR`/`주간 인기`다.
|
||||
- `rankChange=null` 또는 `0`은 `RankingChangeType.Stay`, amount `0`으로 매핑한다.
|
||||
- `rankChange=5`는 `RankingChangeType.Increase`, amount `5`로 매핑한다.
|
||||
- `rankChange=-3`은 `RankingChangeType.Decrease`, amount `3`으로 매핑한다.
|
||||
- `isNew=true`는 `rankChange`보다 우선해 `RankingChangeType.New`로 매핑한다.
|
||||
- `showRankChange=false`이면 모든 rank-num 영역을 `GONE` 처리한다.
|
||||
- `rank < 1` item은 표시하지 않고, `contentId <= 0` item은 클릭 이동을 무시한다.
|
||||
- API 응답은 서버에서 `rank` 오름차순으로 내려오지만, 클라이언트에서도 한 번 더 `rank` 기준 오름차순 정렬한다.
|
||||
- `coverImageUrl=null`은 빈 문자열로 변환하고 기존 이미지 로딩/placeholder 정책을 따른다.
|
||||
- 레거시 `AudioContentRankingAllActivity` 또는 기존 오디오 메인 화면 파일은 직접 수정하지 않는다.
|
||||
- 구현 완료 후 최소 다음 명령을 실행한다.
|
||||
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"`
|
||||
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`
|
||||
- `./gradlew :app:mergeDebugResources`
|
||||
- `./gradlew :app:compileDebugKotlin`
|
||||
- `./gradlew :app:ktlintCheck`
|
||||
- `git diff --check`
|
||||
|
||||
---
|
||||
|
||||
## Figma 참조 필요 Phase
|
||||
- Phase 1: 제한 참조
|
||||
- 기존 콘텐츠 추천 탭, DI, 위젯 구조 확인 중심으로 진행한다.
|
||||
- Phase 2: 제한 참조
|
||||
- 기존 콘텐츠 랭킹 위젯 구조와 Figma의 rank-num 숨김 요구를 대조한다.
|
||||
- Phase 3: Figma 참조 불필요
|
||||
- API/DTO/mapper/ViewModel은 PRD 서버 계약과 기존 V2 추천 패턴을 따른다.
|
||||
- Phase 4: 필수 참조
|
||||
- Text Tab bar, Capsule Tab bar, 랭킹 목록 위치와 visibility 전환은 Figma `24:6857`, `24:6882`를 기준으로 확인한다.
|
||||
- Phase 5: 제한 참조
|
||||
- 클릭 routing, loading/error/empty는 기존 `ContentMainFragment` 패턴 중심으로 검증한다.
|
||||
- Phase 6: 필수 참조
|
||||
- 최종 수동 화면 검증은 PRD의 모든 포함/제외 항목과 실제 화면을 대조한다.
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItem.kt`
|
||||
- `showRankChange` 표시 계약을 기본값 `true`로 추가한다.
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt`
|
||||
- `showRankChange=false`일 때 `ll_content_ranking_delta`를 `GONE` 처리한다.
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingGridCardView.kt`
|
||||
- 2~10위 grid card의 rank-num `GONE` 처리를 추가한다.
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt`
|
||||
- 11위 이후 horizontal card의 rank-num `GONE` 처리를 추가한다.
|
||||
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingCardViewTest.kt`
|
||||
- rank-num visibility와 클릭 가능 상태를 Robolectric으로 검증한다.
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItemTest.kt`
|
||||
- `showRankChange` 기본값과 false 계약 테스트를 추가한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/data/AudioRankingsApi.kt`
|
||||
- `GET /api/v2/audio/rankings?type=...` Retrofit endpoint를 정의한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/data/AudioRankingsModels.kt`
|
||||
- `AudioRankingResponse`, `AudioRankingType`, `AudioRankingItemResponse` DTO를 정의한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/data/AudioRankingsRepository.kt`
|
||||
- API 호출을 Repository method로 감싼다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRankingsUiState.kt`
|
||||
- `Loading`, `Content`, `Empty`, `Error` 상태와 선택 type 상태를 정의한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRankingsMappers.kt`
|
||||
- DTO를 `ContentRankingItem` 목록으로 변환한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModel.kt`
|
||||
- 랭킹 API 호출, selected type, loading/error/content/toast 상태를 관리한다.
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
|
||||
- `AudioRankingsApi`, `AudioRankingsRepository`, `ContentRankingViewModel`을 Koin에 등록한다.
|
||||
- Modify: `app/src/main/res/layout/fragment_v2_main_content.xml`
|
||||
- Text Tab bar에 `추천`, `랭킹`, `전체`가 표시될 수 있도록 기존 구조를 유지하면서 랭킹 Capsule Tab bar와 RecyclerView를 추가한다.
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
|
||||
- Text Tab 전환, ranking type tab, ranking adapter, ViewModel observe, 오디오 상세 routing을 연결한다.
|
||||
- Modify: `app/src/main/res/values/strings.xml`, `app/src/main/res/values-en/strings.xml`, `app/src/main/res/values-ja/strings.xml`
|
||||
- `추천`, `랭킹`, `전체`, 랭킹 유형 라벨 문자열을 추가하거나 기존 문자열을 재사용한다.
|
||||
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/AudioRankingsMapperTest.kt`
|
||||
- DTO -> `ContentRankingItem` 변환 규칙을 검증한다.
|
||||
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/AudioRankingTypeTest.kt`
|
||||
- type별 query value와 UI label 순서를 검증한다.
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt`
|
||||
- 랭킹 layout id, tab label, adapter/ViewModel observe, routing source를 검증한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: 기존 구조 확인과 작업 경계 고정
|
||||
|
||||
- [x] **Task 1.1: 기존 콘텐츠 추천 탭 구조 확인**
|
||||
- 확인:
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainViewModel.kt`
|
||||
- `app/src/main/res/layout/fragment_v2_main_content.xml`
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
|
||||
- 작업:
|
||||
- 추천 탭 `NestedScrollView`와 section visibility 구조를 확인한다.
|
||||
- 랭킹 탭은 기존 추천 section을 리팩터링하지 않고 별도 `RecyclerView` container로 추가하는 경계로 고정한다.
|
||||
- `ContentMainViewModel`은 추천 API 전용으로 유지하고, 랭킹은 신규 `ContentRankingViewModel`로 분리한다.
|
||||
- 검증:
|
||||
- Run: `rg -n "ContentMainViewModel|loadRecommendations|nsv_content_recommendation_content|textTabBarContent|AudioRecommendationsApi" app/src/main/java app/src/main/res`
|
||||
- Expected: 추천 탭 구조와 DI 등록 지점이 확인된다.
|
||||
- 검증 기록:
|
||||
- 2026-06-24: `rg -n "ContentMainViewModel|loadRecommendations|nsv_content_recommendation_content|textTabBarContent|AudioRecommendationsApi" app/src/main/java app/src/main/res` 실행. `ContentMainFragment`, `ContentMainViewModel`, `fragment_v2_main_content.xml`, `AppDI.kt`, `AudioRecommendationsApi`/Repository 참조가 확인되어 추천 탭 구조와 DI 등록 지점을 확인했다.
|
||||
|
||||
- [x] **Task 1.2: 콘텐츠 랭킹 위젯 확장 지점 확인**
|
||||
- 확인:
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItem.kt`
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt`
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingGridCardView.kt`
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt`
|
||||
- 작업:
|
||||
- `showRankChange` 필드가 없음을 확인한다.
|
||||
- 세 카드 View의 `bindDelta(item)` 시작부가 rank-num visibility 확장 지점임을 확인한다.
|
||||
- `ContentRankingAdapter`의 mixed layout 정책은 그대로 재사용하는 것으로 고정한다.
|
||||
- 검증:
|
||||
- Run: `rg -n "showRankChange|bindDelta|ll_content_ranking_delta|ContentRankingPlacement" app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking`
|
||||
- Expected: `showRankChange`는 없고, `bindDelta`와 `ll_content_ranking_delta` 참조가 확인된다.
|
||||
- 검증 기록:
|
||||
- 2026-06-24: `rg -n "showRankChange|bindDelta|ll_content_ranking_delta|ContentRankingPlacement" app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking` 실행. 기존 `showRankChange`는 없고 `bindDelta`, `ll_content_ranking_delta`, `ContentRankingPlacement` 참조가 확인되어 확장 지점을 고정했다.
|
||||
|
||||
- [x] **Task 1.3: 구현 제외 범위 재확인**
|
||||
- 확인:
|
||||
- `docs/20260623_메인_콘텐츠_탭_내부_랭킹_탭/prd.md`
|
||||
- 제외:
|
||||
- Text Tab bar `전체`의 실제 content 화면/API 연동
|
||||
- 레거시 `AudioContentRankingAllActivity` 수정
|
||||
- pagination, pull-to-refresh, skeleton/shimmer
|
||||
- analytics/logging 추가
|
||||
- 추천 탭 section 구조 리팩터링
|
||||
- 검증:
|
||||
- Run: `rg -n "전체|Non-Goals|AudioContentRankingAllActivity|pagination|skeleton" docs/20260623_메인_콘텐츠_탭_내부_랭킹_탭/prd.md`
|
||||
- Expected: 제외 범위가 PRD와 일치한다.
|
||||
- 검증 기록:
|
||||
- 2026-06-24: `rg -n "전체|Non-Goals|AudioContentRankingAllActivity|pagination|skeleton" docs/20260623_메인_콘텐츠_탭_내부_랭킹_탭/prd.md` 실행. `전체` 탭 API/content 제외, legacy `AudioContentRankingAllActivity` 미수정, pagination/skeleton 제외 범위를 재확인했다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: `contentranking` 위젯 rank-num 숨김 계약 확장
|
||||
|
||||
- [x] **Task 2.1: `ContentRankingItem` 표시 계약 테스트 추가**
|
||||
- 수정:
|
||||
- `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItemTest.kt`
|
||||
- 추가 테스트:
|
||||
- `showRankChange` 기본값은 `true`
|
||||
- `showRankChange=false`인 item은 순위 변동 표시 대상이 아님
|
||||
- 기존 `isBlocked`/`isTouchable` 동작은 유지
|
||||
- 구현 예시:
|
||||
```kotlin
|
||||
@Test
|
||||
fun `showRankChange 기본값은 true다`() {
|
||||
val item = sampleItem()
|
||||
|
||||
assertTrue(item.showRankChange)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showRankChange false item은 rank change를 표시하지 않는다`() {
|
||||
val item = sampleItem(showRankChange = false)
|
||||
|
||||
assertFalse(item.showRankChange)
|
||||
}
|
||||
```
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingItemTest"`
|
||||
- Expected: `showRankChange` 속성 미구현으로 RED 실패.
|
||||
- 검증 기록:
|
||||
- 2026-06-24 RED: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingItemTest"` 실행 시 `Unresolved reference 'showRankChange'`, `No parameter with name 'showRankChange' found`로 실패해 미구현 상태를 확인했다. 같은 실행에서 Phase 3 RED 테스트의 `AudioRankingType` 미구현 실패도 함께 확인되었다.
|
||||
|
||||
- [x] **Task 2.2: `ContentRankingItem`에 `showRankChange` 추가**
|
||||
- 수정:
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItem.kt`
|
||||
- 구현:
|
||||
- 기존 생성자 마지막에 `val showRankChange: Boolean = true`를 추가한다.
|
||||
- 기본값을 둬 기존 호출부 변경을 최소화한다.
|
||||
- 코드 형태:
|
||||
```kotlin
|
||||
data class ContentRankingItem(
|
||||
val contentId: String,
|
||||
val creatorId: String,
|
||||
val rank: Int,
|
||||
val previousRank: Int?,
|
||||
val rankChangeType: RankingChangeType,
|
||||
val rankChangeAmount: Int?,
|
||||
val contentName: String,
|
||||
val creatorName: String,
|
||||
val imageUrl: String,
|
||||
val isBlocked: Boolean,
|
||||
val showRankChange: Boolean = true
|
||||
)
|
||||
```
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingItemTest"`
|
||||
- Expected: PASS.
|
||||
- 검증 기록:
|
||||
- 2026-06-24 GREEN: `ContentRankingItem`에 `showRankChange: Boolean = true`를 추가한 뒤 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
- [x] **Task 2.3: rank-num `GONE` view 테스트 추가**
|
||||
- 생성:
|
||||
- `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingCardViewTest.kt`
|
||||
- 테스트 케이스:
|
||||
- `ContentRankingLargeCardView`에 `showRankChange=false` item bind 시 `ll_content_ranking_delta`가 `GONE`
|
||||
- `ContentRankingMediumGridCardView`에 `showRankChange=false` item bind 시 `ll_content_ranking_delta`가 `GONE`
|
||||
- `ContentRankingSmallGridCardView`에 `showRankChange=false` item bind 시 `ll_content_ranking_delta`가 `GONE`
|
||||
- `ContentRankingHorizontalCardView`에 `showRankChange=false` item bind 시 `ll_content_ranking_delta`가 `GONE`
|
||||
- `showRankChange=true` item bind 시 `ll_content_ranking_delta`가 `VISIBLE`
|
||||
- 구현 예시:
|
||||
```kotlin
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ContentRankingCardViewTest {
|
||||
@Test
|
||||
fun `large card는 showRankChange false이면 rank num을 숨긴다`() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val view = LayoutInflater.from(context)
|
||||
.inflate(R.layout.view_content_ranking_large_card, null, false) as ContentRankingLargeCardView
|
||||
|
||||
view.bind(sampleItem(showRankChange = false))
|
||||
|
||||
assertEquals(View.GONE, view.findViewById<View>(R.id.ll_content_ranking_delta).visibility)
|
||||
}
|
||||
}
|
||||
```
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingCardViewTest"`
|
||||
- Expected: card View rank-num 숨김 미구현으로 RED 실패.
|
||||
- 검증 기록:
|
||||
- 2026-06-24: `ContentRankingCardViewTest`를 추가해 large/medium grid/small grid/horizontal card의 `showRankChange=false` 시 `ll_content_ranking_delta` `GONE`, `true` 시 `VISIBLE` 계약을 검증하도록 했다. 기존 Phase 3 RED 테스트가 전체 unit-test 컴파일을 막아 card 테스트 단독 RED는 별도 분리 실행하지 못했고, 구현 후 widget 패키지 검증에서 함께 GREEN을 확인했다.
|
||||
|
||||
- [x] **Task 2.4: rank-num `GONE` 처리 구현**
|
||||
- 수정:
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt`
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingGridCardView.kt`
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt`
|
||||
- 구현:
|
||||
- 각 `bindDelta(item)` 시작부에서 `item.showRankChange == false`이면 delta container를 `View.GONE`으로 설정하고 return한다.
|
||||
- `item.showRankChange == true`이면 delta container를 `View.VISIBLE`로 복구한 뒤 기존 presentation 적용을 유지한다.
|
||||
- 코드 형태:
|
||||
```kotlin
|
||||
private fun bindDelta(item: ContentRankingItem) {
|
||||
val deltaView = requireNotNull(deltaGroup)
|
||||
if (!item.showRankChange) {
|
||||
deltaView.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
deltaView.visibility = View.VISIBLE
|
||||
|
||||
val presentation = ContentRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
||||
applyDeltaContainer(presentation)
|
||||
...
|
||||
}
|
||||
```
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"`
|
||||
- Expected: PASS.
|
||||
- 검증 기록:
|
||||
- 2026-06-24: `ContentRankingLargeCardView`, `ContentRankingGridCardView`, `ContentRankingHorizontalCardView`의 `bindDelta(item)` 시작부에서 `showRankChange=false`이면 delta container를 `View.GONE` 처리하고 return하도록 구현했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 랭킹 API/DTO/mapper 작성
|
||||
|
||||
- [x] **Task 3.1: `AudioRankingType` 라벨/query 테스트 작성**
|
||||
- 생성:
|
||||
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/AudioRankingTypeTest.kt`
|
||||
- 테스트 케이스:
|
||||
- `AudioRankingType.entries` 순서가 `WEEKLY_POPULAR`, `RISING`, `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`
|
||||
- query value는 enum name 그대로 사용
|
||||
- label은 `주간 인기`, `지금 뜨는 중`, `매출`, `판매량`, `댓글수`, `좋아요` 순서
|
||||
- 구현 예시:
|
||||
```kotlin
|
||||
@Test
|
||||
fun `랭킹 타입은 PRD 순서를 유지한다`() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
AudioRankingType.WEEKLY_POPULAR,
|
||||
AudioRankingType.RISING,
|
||||
AudioRankingType.REVENUE,
|
||||
AudioRankingType.SALES_COUNT,
|
||||
AudioRankingType.COMMENT_COUNT,
|
||||
AudioRankingType.LIKE_COUNT
|
||||
),
|
||||
AudioRankingType.entries
|
||||
)
|
||||
}
|
||||
```
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.AudioRankingTypeTest"`
|
||||
- Expected: DTO/type 미구현으로 RED 실패.
|
||||
- 검증 기록:
|
||||
- 2026-06-24 RED: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.AudioRankingTypeTest"` 실행 시 `Unresolved reference 'AudioRankingType'`로 실패해 type 미구현 상태를 확인했다.
|
||||
|
||||
- [x] **Task 3.2: mapper RED 테스트 작성**
|
||||
- 생성:
|
||||
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/AudioRankingsMapperTest.kt`
|
||||
- 테스트 케이스:
|
||||
- 응답 item을 `rank` 오름차순으로 정렬한다.
|
||||
- `rank < 1` item은 제외한다.
|
||||
- `isNew=true`는 `RankingChangeType.New`, amount `0`으로 매핑한다.
|
||||
- `rankChange=null` 또는 `0`은 `RankingChangeType.Stay`, amount `0`으로 매핑한다.
|
||||
- `rankChange=5`는 `RankingChangeType.Increase`, amount `5`로 매핑한다.
|
||||
- `rankChange=-3`은 `RankingChangeType.Decrease`, amount `3`으로 매핑한다.
|
||||
- `showRankChange=false`이면 모든 `ContentRankingItem.showRankChange=false`로 매핑한다.
|
||||
- `coverImageUrl=null`은 `imageUrl=""`로 매핑한다.
|
||||
- `creatorId`는 API에 없으므로 `ContentRankingItem.creatorId=""`로 매핑한다.
|
||||
- `isBlocked=false`로 매핑한다.
|
||||
- 구현 예시:
|
||||
```kotlin
|
||||
@Test
|
||||
fun `rankChange 음수는 decrease와 절대값 amount로 매핑된다`() {
|
||||
val item = response(
|
||||
items = listOf(rankingItem(rank = 1, rankChange = -3, isNew = false))
|
||||
).toContentRankingItems().single()
|
||||
|
||||
assertEquals(RankingChangeType.Decrease, item.rankChangeType)
|
||||
assertEquals(3, item.rankChangeAmount)
|
||||
}
|
||||
```
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.AudioRankingsMapperTest"`
|
||||
- Expected: DTO/mapper 미구현으로 RED 실패.
|
||||
- 검증 기록:
|
||||
- 2026-06-24: `AudioRankingsMapperTest`를 추가해 rank 정렬/필터, `isNew` 우선, `rankChange` null/0/양수/음수, `showRankChange=false`, `coverImageUrl=null`, `creatorId=""`, `isBlocked=false` 매핑 계약을 검증하도록 했다. 직전 `AudioRankingTypeTest` RED에서 DTO/type 미구현 컴파일 실패를 확인한 뒤 같은 미구현 상태를 전제로 작성했다.
|
||||
|
||||
- [x] **Task 3.3: API DTO와 mapper 구현**
|
||||
- 생성:
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/data/AudioRankingsModels.kt`
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRankingsMappers.kt`
|
||||
- 구현:
|
||||
- `@Keep data class AudioRankingResponse(...)`
|
||||
- `enum class AudioRankingType(...)`
|
||||
- `@Keep data class AudioRankingItemResponse(...)`
|
||||
- PRD 응답 계약에 맞춰 `AudioRankingResponse`에 `type: AudioRankingType`을 포함한다.
|
||||
- Gson `@SerializedName("isNew")`를 사용하고 Jackson `@JsonProperty`는 사용하지 않는다.
|
||||
- `fun AudioRankingResponse.toContentRankingItems(): List<ContentRankingItem>`를 추가한다.
|
||||
- `fun AudioRankingType.labelResId(): Int` 또는 `label(context)` helper는 UI 연결 Phase에서 문자열 리소스와 함께 확정한다.
|
||||
- 코드 형태:
|
||||
```kotlin
|
||||
@Keep
|
||||
data class AudioRankingItemResponse(
|
||||
@SerializedName("contentId") val contentId: Long,
|
||||
@SerializedName("title") val title: String,
|
||||
@SerializedName("creatorNickname") val creatorNickname: String,
|
||||
@SerializedName("rank") val rank: Int,
|
||||
@SerializedName("rankChange") val rankChange: Int?,
|
||||
@SerializedName("isNew") val isNew: Boolean,
|
||||
@SerializedName("coverImageUrl") val coverImageUrl: String?
|
||||
)
|
||||
```
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.AudioRankingTypeTest"`
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.AudioRankingsMapperTest"`
|
||||
- Expected: PASS.
|
||||
- 검증 기록:
|
||||
- 2026-06-24: `AudioRankingsModels.kt`와 `AudioRankingsMappers.kt`를 추가했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 2026-06-24 리뷰 보완: 리뷰 게이트에서 PRD 응답 계약의 `type` 누락이 확인되어 `AudioRankingResponse.type: AudioRankingType`을 추가했다.
|
||||
|
||||
- [x] **Task 3.4: API/Repository 작성**
|
||||
- 생성:
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/data/AudioRankingsApi.kt`
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/data/AudioRankingsRepository.kt`
|
||||
- 구현:
|
||||
- `@GET("/api/v2/audio/rankings")`
|
||||
- `@Query("type") type: AudioRankingType`
|
||||
- `@Header("Authorization") authHeader: String`
|
||||
- 반환 타입은 `Single<ApiResponse<AudioRankingResponse>>`
|
||||
- 코드 형태:
|
||||
```kotlin
|
||||
interface AudioRankingsApi {
|
||||
@GET("/api/v2/audio/rankings")
|
||||
fun getRankings(
|
||||
@Header("Authorization") authHeader: String,
|
||||
@Query("type") type: AudioRankingType
|
||||
): Single<ApiResponse<AudioRankingResponse>>
|
||||
}
|
||||
```
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:compileDebugKotlin`
|
||||
- Expected: 신규 API/Repository 컴파일 성공.
|
||||
- 검증 기록:
|
||||
- 2026-06-24: `AudioRankingsApi.kt`, `AudioRankingsRepository.kt`를 추가했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`에서 신규 API/Repository를 포함한 debug/test 컴파일과 관련 테스트 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: ViewModel과 DI 등록
|
||||
|
||||
- [ ] **Task 4.1: UI state와 ViewModel 작성**
|
||||
- 생성:
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRankingsUiState.kt`
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModel.kt`
|
||||
- 구현:
|
||||
- `AudioRankingsUiState.Loading`
|
||||
- `AudioRankingsUiState.Content(val type: AudioRankingType, val items: List<ContentRankingItem>)`
|
||||
- `AudioRankingsUiState.Empty(val type: AudioRankingType)`
|
||||
- `AudioRankingsUiState.Error(val type: AudioRankingType, val message: String?)`
|
||||
- `rankingStateLiveData`, `toastLiveData`, `isLoading`, `selectedTypeLiveData`
|
||||
- `loadRankings(type: AudioRankingType, force: Boolean = false)`
|
||||
- 같은 type을 이미 성공 로드했고 `force=false`이면 중복 호출하지 않는다.
|
||||
- 응답 `type`이 현재 선택 type과 다르면 화면 반영을 무시한다.
|
||||
- 기존 `ContentMainViewModel`과 동일하게 `SharedPreferenceManager.token`, RxJava scheduler, unknown error toast 패턴을 사용한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:compileDebugKotlin`
|
||||
- Expected: ViewModel 컴파일 성공.
|
||||
- 검증 기록:
|
||||
- 구현 시 실행 결과를 이 아래에 누적한다.
|
||||
|
||||
- [ ] **Task 4.2: Koin DI 등록**
|
||||
- 수정:
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
|
||||
- 구현:
|
||||
- import 추가: `AudioRankingsApi`, `AudioRankingsRepository`, `ContentRankingViewModel`
|
||||
- API 등록: `single { ApiBuilder().build(get(), AudioRankingsApi::class.java) }`
|
||||
- Repository 등록: `factory { AudioRankingsRepository(get()) }`
|
||||
- ViewModel 등록: `viewModel { ContentRankingViewModel(get()) }`
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:compileDebugKotlin`
|
||||
- Expected: Koin 등록과 import 컴파일 성공.
|
||||
- 검증 기록:
|
||||
- 구현 시 실행 결과를 이 아래에 누적한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 레이아웃과 탭 전환 UI 연결
|
||||
|
||||
- [ ] **Task 5.1: 랭킹 layout/source RED 테스트 추가**
|
||||
- 수정:
|
||||
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt`
|
||||
- 추가 테스트:
|
||||
- `fragment_v2_main_content.xml`에 `view_content_ranking_type_tabs`가 존재한다.
|
||||
- `fragment_v2_main_content.xml`에 `rv_content_rankings`가 존재한다.
|
||||
- `ContentMainFragment` source에 `ContentRankingViewModel by viewModel()`이 존재한다.
|
||||
- `ContentMainFragment` source에 `ContentRankingAdapter.createGridLayoutManager(requireContext())`가 존재한다.
|
||||
- `ContentMainFragment` source에 `AudioRankingType.WEEKLY_POPULAR` 초기 로드가 존재한다.
|
||||
- `ContentMainFragment` source에 Text Tab 메뉴 `추천`, `랭킹`, `전체` 설정이 존재한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
|
||||
- Expected: layout id/ViewModel/adapter 연결 미구현으로 RED 실패.
|
||||
- 검증 기록:
|
||||
- 구현 시 실행 결과를 이 아래에 누적한다.
|
||||
|
||||
- [ ] **Task 5.2: strings 추가**
|
||||
- 수정:
|
||||
- `app/src/main/res/values/strings.xml`
|
||||
- `app/src/main/res/values-en/strings.xml`
|
||||
- `app/src/main/res/values-ja/strings.xml`
|
||||
- 구현:
|
||||
- `screen_content_tab_recommendation`
|
||||
- `screen_content_tab_ranking`
|
||||
- `screen_content_tab_all`
|
||||
- `screen_content_ranking_type_weekly_popular`
|
||||
- `screen_content_ranking_type_rising`
|
||||
- `screen_content_ranking_type_revenue`
|
||||
- `screen_content_ranking_type_sales_count`
|
||||
- `screen_content_ranking_type_comment_count`
|
||||
- `screen_content_ranking_type_like_count`
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:mergeDebugResources`
|
||||
- Expected: 문자열 리소스 merge 성공.
|
||||
- 검증 기록:
|
||||
- 구현 시 실행 결과를 이 아래에 누적한다.
|
||||
|
||||
- [ ] **Task 5.3: `fragment_v2_main_content.xml`에 랭킹 container 추가**
|
||||
- 수정:
|
||||
- `app/src/main/res/layout/fragment_v2_main_content.xml`
|
||||
- 구현:
|
||||
- 기존 `text_tab_bar_content` 아래에 랭킹 type tab include를 추가한다.
|
||||
- `@+id/view_content_ranking_type_tabs`, `layout="@layout/view_capsule_tab_bar"`, 초기 `android:visibility="gone"`로 둔다.
|
||||
- 랭킹 RecyclerView `@+id/rv_content_rankings`를 추가한다.
|
||||
- `rv_content_rankings`는 `view_content_ranking_type_tabs` 아래부터 parent bottom까지 constraint하고 초기 `gone`으로 둔다.
|
||||
- 기존 `nsv_content_recommendation_content`는 추천 탭 선택 시만 visible로 유지한다.
|
||||
- XML 형태:
|
||||
```xml
|
||||
<include
|
||||
android:id="@+id/view_content_ranking_type_tabs"
|
||||
layout="@layout/view_capsule_tab_bar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="52dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_content" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_content_rankings"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingHorizontal="@dimen/spacing_14"
|
||||
android:paddingTop="@dimen/spacing_14"
|
||||
android:paddingBottom="@dimen/spacing_28"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/view_content_ranking_type_tabs" />
|
||||
```
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:mergeDebugResources`
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
|
||||
- Expected: resource merge 성공, source test는 Fragment 연결 전 일부 RED 유지 가능.
|
||||
- 검증 기록:
|
||||
- 구현 시 실행 결과를 이 아래에 누적한다.
|
||||
|
||||
- [ ] **Task 5.4: `ContentMainFragment`에 Text Tab 전환과 랭킹 탭 UI 연결**
|
||||
- 수정:
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
|
||||
- 구현:
|
||||
- `private val contentRankingViewModel: ContentRankingViewModel by viewModel()` 추가
|
||||
- `ContentRankingAdapter { openAudioContentDetail(it.contentId.toLongOrNull() ?: 0L) }` 추가
|
||||
- `binding.textTabBarContent.root.setMenus(listOf(...추천, ...랭킹, ...전체), selectedIndex = 0)`로 변경
|
||||
- Text Tab index 상수 추가:
|
||||
- `CONTENT_TAB_RECOMMENDATION = 0`
|
||||
- `CONTENT_TAB_RANKING = 1`
|
||||
- `CONTENT_TAB_ALL = 2`
|
||||
- `랭킹` 선택 시 추천 scroll을 `GONE`, ranking type tab과 ranking RecyclerView를 `VISIBLE`로 전환한다.
|
||||
- `전체` 선택 시 항목만 선택되지만 실제 content 구현은 제외하므로 추천/랭킹 content를 모두 숨기거나 기존 화면 정책에 맞춰 빈 화면을 유지한다. 이 동작은 계획 실행 중 사용자 확인 없이 추가 API 연동으로 확장하지 않는다.
|
||||
- `랭킹` 최초 선택 시 `AudioRankingType.WEEKLY_POPULAR`를 로드한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
|
||||
- Expected: Fragment source 연결 관련 assertion PASS.
|
||||
- 검증 기록:
|
||||
- 구현 시 실행 결과를 이 아래에 누적한다.
|
||||
|
||||
- [ ] **Task 5.5: 랭킹 유형 Capsule Tab 연결**
|
||||
- 수정:
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
|
||||
- 구현:
|
||||
- `view_content_ranking_type_tabs.root.setMenus(...)`로 6개 라벨을 설정한다.
|
||||
- `setOnTabSelectedListener`에서 index를 `AudioRankingType.entries[index]`로 변환한다.
|
||||
- 같은 type 재선택은 `CapsuleTabBarView.selectTab()` 내부에서 무시되므로 별도 API 호출을 만들지 않는다.
|
||||
- 탭 전환 시 `contentRankingViewModel.loadRankings(type)`를 호출한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:compileDebugKotlin`
|
||||
- Expected: Capsule Tab 연결 컴파일 성공.
|
||||
- 검증 기록:
|
||||
- 구현 시 실행 결과를 이 아래에 누적한다.
|
||||
|
||||
- [ ] **Task 5.6: ranking observer와 adapter submit 연결**
|
||||
- 수정:
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
|
||||
- 구현:
|
||||
- `rv_content_rankings.layoutManager = ContentRankingAdapter.createGridLayoutManager(requireContext())`
|
||||
- `rv_content_rankings.adapter = contentRankingAdapter`
|
||||
- `rankingStateLiveData.observe(viewLifecycleOwner)`에서 `Content`이면 `submitItems(state.items)`, `Empty`/`Error`이면 `submitItems(emptyList())`
|
||||
- `isLoading`은 기존 `LoadingDialog`와 충돌하지 않도록 추천/랭킹 ViewModel의 loading 상태를 모두 관찰하거나, 랭킹 loading만 별도 observer에서 같은 dialog를 show/dismiss한다.
|
||||
- `toastLiveData`는 기존 `showToast` helper를 재사용한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:compileDebugKotlin`
|
||||
- Expected: Fragment/adapter/ViewModel 연결 컴파일 성공.
|
||||
- 검증 기록:
|
||||
- 구현 시 실행 결과를 이 아래에 누적한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: routing, 통합 검증, 문서 검증 기록
|
||||
|
||||
- [ ] **Task 6.1: 랭킹 item 클릭 routing guard 연결**
|
||||
- 수정:
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
|
||||
- 구현:
|
||||
- `ContentRankingItem.contentId`를 `Long`으로 변환한다.
|
||||
- `contentId > 0`이면 기존 `openAudioContentDetail(audioContentId)`를 호출한다.
|
||||
- 변환 실패 또는 `contentId <= 0`이면 아무 동작하지 않는다.
|
||||
- 코드 형태:
|
||||
```kotlin
|
||||
private fun openRankingAudioContentDetail(item: ContentRankingItem) {
|
||||
val audioContentId = item.contentId.toLongOrNull() ?: return
|
||||
openAudioContentDetail(audioContentId)
|
||||
}
|
||||
```
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
|
||||
- Expected: routing source assertion PASS.
|
||||
- 검증 기록:
|
||||
- 구현 시 실행 결과를 이 아래에 누적한다.
|
||||
|
||||
- [ ] **Task 6.2: mapper/widget/content package 단위 테스트 실행**
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"`
|
||||
- Expected: PASS.
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`
|
||||
- Expected: PASS.
|
||||
- 검증 기록:
|
||||
- 구현 시 실행 결과를 이 아래에 누적한다.
|
||||
|
||||
- [ ] **Task 6.3: 리소스/컴파일/스타일 검증**
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:mergeDebugResources`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- Run: `./gradlew :app:compileDebugKotlin`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- Run: `./gradlew :app:ktlintCheck`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- Run: `git diff --check`
|
||||
- Expected: trailing whitespace 또는 conflict marker 없음
|
||||
- 검증 기록:
|
||||
- 구현 시 실행 결과를 이 아래에 누적한다.
|
||||
|
||||
- [ ] **Task 6.4: 수동 화면 검증**
|
||||
- 확인:
|
||||
- 메인 콘텐츠 탭 진입 시 기본 `추천` 탭 화면이 기존처럼 표시된다.
|
||||
- Text Tab bar에 `추천`, `랭킹`, `전체`이 보인다.
|
||||
- `랭킹` 선택 시 Capsule Tab bar 6개 항목이 Figma 순서로 보인다.
|
||||
- 초기 선택은 `주간 인기`이고 API는 `type=WEEKLY_POPULAR`로 호출된다.
|
||||
- 랭킹 목록은 1위 대형, 2~7위 2열, 8~10위 3열, 11위 이후 가로형으로 표시된다.
|
||||
- `showRankChange=false` 응답에서 rank-num 영역이 보이지 않는다.
|
||||
- `contentId > 0` item 터치 시 오디오 상세로 이동한다.
|
||||
- `전체` 탭은 항목만 표시하고 별도 API/content 구현이 없다.
|
||||
- 검증 기록:
|
||||
- 구현 시 실행 결과를 이 아래에 누적한다.
|
||||
|
||||
---
|
||||
|
||||
## Verification Log
|
||||
- 구현 중 여러 Phase에 걸친 통합 검증, 회귀 검증, 최종 수동 확인 기록을 이 아래에 누적한다.
|
||||
- 2026-06-24: Phase 1~3 구현 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 2026-06-24: Phase 1~3 구현 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 2026-06-24: `./gradlew :app:mergeDebugResources` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 2026-06-24: `./gradlew :app:compileDebugKotlin` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 2026-06-24: `./gradlew :app:ktlintCheck` 1차 실행에서 변경 파일의 기존 긴 `LayoutParams` 라인이 ktlint max line length를 위반해 실패했고, 기능 변경 없이 줄바꿈만 정리한 뒤 재실행 결과 `BUILD SUCCESSFUL`. `.editorconfig`의 `disabled_rules` deprecation 경고는 기존 설정 경고로 남아 있다.
|
||||
- 2026-06-24: `git diff --check` 실행 결과 출력 없음. trailing whitespace 또는 conflict marker 없음.
|
||||
- 2026-06-24: Kotlin LSP 진단은 환경에 `kotlin-lsp`가 설치되어 있지 않아 실행 불가(`Command not found: kotlin-lsp`). Gradle compile/test/ktlint로 대체 검증했다.
|
||||
- 2026-06-24: 리뷰 게이트 보완 후 `AudioRankingResponse.type` 추가. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` 재실행 결과 모두 통과했다.
|
||||
- 2026-06-24: Phase 1~3 코드 리뷰 및 현재 워크트리 기준 재검증을 수행했다. Phase 1 구조 확인용 `rg` 3개 명령에서 기대 참조를 확인했고, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` 실행 결과 모두 통과했다. `./gradlew :app:mergeDebugResources`는 샌드박스 내 Gradle wrapper lock 접근 제한으로 1차 실패 후 권한 상승 실행에서 `BUILD SUCCESSFUL`을 확인했다.
|
||||
274
docs/20260623_메인_콘텐츠_탭_내부_랭킹_탭/prd.md
Normal file
274
docs/20260623_메인_콘텐츠_탭_내부_랭킹_탭/prd.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# PRD: 메인 콘텐츠 탭 내부 랭킹 탭
|
||||
|
||||
## 1. Overview
|
||||
Figma `cont_002` 화면(`24:6857`)을 기준으로 메인 콘텐츠 탭의 내부 `랭킹` 탭을 구성하고, `GET /api/v2/audio/rankings` 응답을 기존 V2 콘텐츠 랭킹 위젯 중심으로 표시한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem
|
||||
- 메인 콘텐츠 탭은 현재 내부 `추천` 탭만 연결되어 있고, Figma에 정의된 내부 `랭킹` 탭 화면과 API 연동 요구가 아직 문서화되어 있지 않다.
|
||||
- 랭킹 화면은 상단 콘텐츠 title-bar, 내부 Text Tab bar, 랭킹 유형 Capsule Tab bar, 순위별 카드 목록이 함께 배치되어야 한다.
|
||||
- API 응답은 `AudioRankingResponse`와 `AudioRankingItemResponse` 형태이고, 기존 `ContentRankingItem` 위젯 계약은 `rankChangeType`, `rankChangeAmount`, `imageUrl`, `isBlocked` 등 별도 필드를 요구하므로 mapper 규칙이 필요하다.
|
||||
- V2 패키지 하위에 이미 `TextTabBarView`, `CapsuleTabBarView`, `ContentRankingAdapter` 및 순위별 카드 위젯이 있으므로 재사용 가능한 후보를 먼저 식별하고 중복 UI 생성을 줄여야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
- 메인 콘텐츠 탭에서 내부 `랭킹` 탭 선택 시 Figma `24:6857` 기준 랭킹 화면을 표시한다.
|
||||
- `GET /api/v2/audio/rankings` 응답을 받아 선택된 랭킹 유형의 오디오 콘텐츠 순위를 표시한다.
|
||||
- 내부 랭킹 유형은 `주간 인기`, `지금 뜨는 중`, `매출`, `판매량`, `댓글수`, `좋아요` 순서로 표시한다.
|
||||
- 기존 `kr.co.vividnext.sodalive.v2.widget.contentranking` 위젯군을 우선 재사용한다.
|
||||
- 랭킹 item 터치 시 유효한 `contentId`를 오디오 콘텐츠 상세 화면으로 전달한다.
|
||||
- API DTO, UI model, mapper, loading/empty/error, tab 선택 동작은 구현 계획에서 검증 가능하도록 정리한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
- 이번 PRD 작성 단계에서는 코드, 리소스, 레이아웃 파일을 구현하지 않는다.
|
||||
- 서버 API 스키마, 랭킹 산정 기준, 정렬 기준은 클라이언트에서 변경하지 않는다.
|
||||
- 콘텐츠 추천 탭의 섹션 구조를 리팩터링하지 않는다.
|
||||
- 레거시 오디오 메인/랭킹 화면을 직접 수정하지 않는다.
|
||||
- Compose 전환, ViewPager2 기반 swipe tab 전환, pagination, pull-to-refresh, skeleton/shimmer는 이번 범위에 포함하지 않는다.
|
||||
- Figma에 없는 추가 필터, 검색, 상세 통계, 랭킹 설명 문구, 광고 영역은 추가하지 않는다.
|
||||
- `coverImageUrl` 외 별도 이미지 asset을 새로 제작하지 않는다.
|
||||
- Text Tab bar 영역의 `전체` 탭은 화면에 항목만 표시하고, 실제 content 화면/API 연동 구현은 이번 범위에서 제외한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Users
|
||||
- 메인 콘텐츠 탭에서 인기/상승/매출/판매량/댓글/좋아요 기준 오디오 순위를 탐색하려는 앱 사용자.
|
||||
- 랭킹 카드에서 순위, 순위 변동, 신규 진입 여부, 제목, 크리에이터 닉네임을 빠르게 확인하려는 앱 사용자.
|
||||
- 랭킹 item을 눌러 오디오 콘텐츠 상세로 이동하려는 앱 사용자.
|
||||
- `kr.co.vividnext.sodalive.v2.main.content`와 V2 랭킹 위젯을 유지보수하는 Android 개발자.
|
||||
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
- 사용자는 콘텐츠 탭에서 `랭킹`을 눌러 콘텐츠 랭킹 목록을 바로 보고 싶다.
|
||||
- 사용자는 `주간 인기`, `지금 뜨는 중`, `매출`, `판매량`, `댓글수`, `좋아요` 기준을 전환하며 순위를 비교하고 싶다.
|
||||
- 사용자는 1위, 2~7위, 8~10위, 11위 이후 카드가 시각적으로 구분되길 기대한다.
|
||||
- 사용자는 `New` 또는 순위 상승/하락/유지 상태를 카드에서 확인하고 싶다.
|
||||
- 사용자는 랭킹 item을 터치해 해당 오디오 콘텐츠 상세로 이동하고 싶다.
|
||||
- 개발자는 신규 API 응답을 기존 `ContentRankingAdapter` 계약으로 명확히 변환해 UI 중복 구현을 피하고 싶다.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Features
|
||||
|
||||
### 메인 콘텐츠 내부 랭킹 탭
|
||||
메인 콘텐츠 화면의 내부 Text Tab bar에서 `랭킹` 선택 상태일 때 랭킹 유형 탭과 랭킹 목록을 표시한다.
|
||||
|
||||
#### Requirements
|
||||
- 내부 Text Tab bar 항목은 Figma 기준 `추천`, `랭킹`, `전체` 순서로 표시한다.
|
||||
- `랭킹` 선택 시 Text Tab bar selected index가 `랭킹`으로 갱신되어야 한다.
|
||||
- title-bar와 Text Tab bar는 기존 메인 콘텐츠 화면과 동일하게 상단에 유지한다.
|
||||
- 랭킹 유형 Capsule Tab bar는 Text Tab bar 아래에 배치한다.
|
||||
- 랭킹 목록은 Capsule Tab bar 아래에서 세로 스크롤 가능해야 한다.
|
||||
- 추천 탭으로 다시 전환하면 기존 추천 탭 content가 표시되어야 한다.
|
||||
- Text Tab bar 영역의 `전체` 탭은 항목만 표시하고, 선택 시 표시할 실제 content 화면/API 연동은 이번 범위에서 구현하지 않는다.
|
||||
|
||||
#### Edge Cases
|
||||
- 랭킹 탭을 반복 선택해도 동일 타입 API를 불필요하게 중복 호출하지 않는 정책을 구현 계획에서 결정한다.
|
||||
- `items`가 비어 있으면 랭킹 목록 영역을 비우고, Figma에 없는 별도 empty 문구는 추가하지 않는다.
|
||||
- API error 시 기존 `ContentMainViewModel`의 error/toast/loading 정책과 일관되게 처리한다.
|
||||
|
||||
### Audio Ranking API Integration
|
||||
랭킹 화면은 `GET /api/v2/audio/rankings` 응답을 받아 기존 콘텐츠 랭킹 위젯용 UI model로 변환한다.
|
||||
|
||||
#### API Endpoint
|
||||
- `GET /api/v2/audio/rankings`
|
||||
|
||||
#### Request Query
|
||||
선택한 랭킹 유형은 `type` query parameter로 전달한다.
|
||||
|
||||
```http
|
||||
GET /api/v2/audio/rankings?type=WEEKLY_POPULAR
|
||||
```
|
||||
|
||||
#### Response Contract
|
||||
```kotlin
|
||||
data class AudioRankingResponse(
|
||||
val showRankChange: Boolean,
|
||||
val type: AudioRankingType,
|
||||
val items: List<AudioRankingItemResponse>
|
||||
)
|
||||
|
||||
enum class AudioRankingType {
|
||||
WEEKLY_POPULAR,
|
||||
RISING,
|
||||
REVENUE,
|
||||
SALES_COUNT,
|
||||
COMMENT_COUNT,
|
||||
LIKE_COUNT
|
||||
}
|
||||
|
||||
data class AudioRankingItemResponse(
|
||||
val contentId: Long,
|
||||
val title: String,
|
||||
val creatorNickname: String,
|
||||
val rank: Int,
|
||||
val rankChange: Int?,
|
||||
@JsonProperty("isNew")
|
||||
val isNew: Boolean,
|
||||
val coverImageUrl: String?
|
||||
)
|
||||
```
|
||||
|
||||
#### Mapping Requirements
|
||||
| Response field | UI/widget field | Requirement |
|
||||
| --- | --- | --- |
|
||||
| `items[].contentId` | `ContentRankingItem.contentId` | 문자열 변환 후 전달한다. |
|
||||
| `items[].rank` | `ContentRankingItem.rank` | `1` 이상인 item만 표시한다. |
|
||||
| `items[].title` | `ContentRankingItem.contentName` | 그대로 전달하고, 말줄임은 기존 위젯 정책을 따른다. |
|
||||
| `items[].creatorNickname` | `ContentRankingItem.creatorName` | 그대로 전달한다. |
|
||||
| `items[].coverImageUrl` | `ContentRankingItem.imageUrl` | null이면 빈 문자열로 변환하고 기존 이미지 로딩/placeholder 정책을 따른다. |
|
||||
| `items[].isNew` | `ContentRankingItem.rankChangeType` | `true`이면 `RankingChangeType.New`로 매핑한다. |
|
||||
| `items[].rankChange` | `ContentRankingItem.rankChangeType`, `ContentRankingItem.rankChangeAmount` | `null` 또는 `0`이면 `Stay`, 양수이면 `Increase`, 음수이면 `Decrease`로 매핑하고 표시 숫자는 절대값을 사용한다. |
|
||||
| `showRankChange` | 순위 변동 표시 여부 | `false`이면 모든 item의 rank-num 영역을 `GONE` 처리한다. 기존 위젯에 옵션이 없으면 최소 확장이 필요하다. |
|
||||
| `type` | 선택된 랭킹 유형 상태 | 응답 타입과 현재 선택 탭이 다르면 구현 계획에서 무시/수용 정책을 명확히 정한다. |
|
||||
|
||||
#### Rank Change Rules
|
||||
- `isNew=true`이면 `rankChange` 값과 무관하게 `RankingChangeType.New`로 표시한다.
|
||||
- `isNew=false && rankChange=null`이면 `RankingChangeType.Stay`, amount `0`으로 매핑한다.
|
||||
- `isNew=false && rankChange=0`이면 `RankingChangeType.Stay`, amount `0`으로 매핑한다.
|
||||
- `isNew=false && rankChange=5`이면 `RankingChangeType.Increase`, amount `5`로 매핑한다.
|
||||
- `isNew=false && rankChange=-3`이면 `RankingChangeType.Decrease`, amount `3`으로 매핑한다.
|
||||
- 일반화하면 `rankChange > 0`은 `Increase`, `rankChange < 0`은 `Decrease`로 매핑하고 표시 숫자는 `abs(rankChange)`를 사용한다.
|
||||
- `showRankChange=false`이면 `isNew`, `rankChange` 값과 무관하게 rank-num 영역을 `GONE` 처리한다.
|
||||
- API 응답 item은 서버에서 `rank` 오름차순으로 내려온다고 기대하되, 클라이언트에서도 `rank` 기준 오름차순 정렬 후 표시한다.
|
||||
|
||||
#### Edge Cases
|
||||
- `rank < 1` item은 기존 `ContentRankingItem` 생성 조건에 맞지 않으므로 표시하지 않는다.
|
||||
- `contentId <= 0` item은 표시할 수는 있으나 클릭 이동은 무시한다.
|
||||
- 동일 rank가 중복되면 서버 데이터 오류로 보고 클라이언트는 `rank` 정렬 결과를 그대로 표시한다. 중복 보정 UI는 추가하지 않는다.
|
||||
- `title` 또는 `creatorNickname`이 빈 문자열이면 빈 문자열 그대로 표시하고 대체 문구를 추가하지 않는다.
|
||||
- `coverImageUrl`이 null/blank이거나 이미지 로딩에 실패하면 기존 `loadUrl`/placeholder 정책을 따른다.
|
||||
|
||||
### Ranking Type Capsule Tab
|
||||
Figma `24:6882` 기준으로 랭킹 유형 필터를 가로 스크롤 capsule tab으로 표시한다.
|
||||
|
||||
#### Requirements
|
||||
- 탭 라벨과 타입 매핑은 아래 순서를 따른다.
|
||||
|
||||
| UI Label | API Type |
|
||||
| --- | --- |
|
||||
| `주간 인기` | `WEEKLY_POPULAR` |
|
||||
| `지금 뜨는 중` | `RISING` |
|
||||
| `매출` | `REVENUE` |
|
||||
| `판매량` | `SALES_COUNT` |
|
||||
| `댓글수` | `COMMENT_COUNT` |
|
||||
| `좋아요` | `LIKE_COUNT` |
|
||||
|
||||
- 초기 선택은 Figma와 enum 첫 항목 기준 `주간 인기`로 한다.
|
||||
- 선택된 tab은 흰색 배경/검은색 텍스트, 미선택 tab은 검은색 배경/회색 border/흰색 텍스트 스타일을 따른다.
|
||||
- 기존 `CapsuleTabBarView` 재사용을 우선한다.
|
||||
- 탭 선택 시 선택 상태를 갱신하고 해당 타입의 랭킹 데이터를 로드한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 같은 tab을 다시 누른 경우 선택 상태와 목록을 유지한다.
|
||||
- 네트워크 요청 중 다른 tab을 선택하면 마지막으로 선택된 tab의 응답만 화면에 반영하는 정책을 구현 계획에서 검증한다.
|
||||
|
||||
### Content Ranking Widget Reuse
|
||||
기존 `kr.co.vividnext.sodalive.v2.widget.contentranking` 컴포넌트를 랭킹 목록에 사용한다.
|
||||
|
||||
#### Existing Widget Fit
|
||||
- Figma `24:6857`의 카드 구조는 기존 콘텐츠 랭킹 위젯의 순위 구간과 일치한다.
|
||||
- 1위: `ContentRankingCardVariant.Large`
|
||||
- 2위~7위: `ContentRankingCardVariant.MediumGrid`
|
||||
- 8위~10위: `ContentRankingCardVariant.SmallGrid`
|
||||
- 11위 이후: `ContentRankingCardVariant.Horizontal`
|
||||
- 기존 `ContentRankingAdapter.createGridLayoutManager()`의 span 정책을 사용한다.
|
||||
- Pattaya/Galada 계열 순위 숫자, dim gradient, New badge, caret rank-num은 기존 위젯 정책을 따른다.
|
||||
- `ContentRankingItem.creatorId`는 API 응답에 없으므로 빈 문자열로 채운다. 이번 화면의 클릭 목적지는 `contentId`만 사용한다.
|
||||
- `ContentRankingItem.isBlocked`에 대응되는 API 필드가 없으므로 기본값은 `false`로 한다.
|
||||
|
||||
#### Required Change Candidates
|
||||
- `showRankChange=false`일 때 rank-num 영역을 `GONE` 처리하는 옵션이 기존 `ContentRanking*CardView`에 없으면 최소 확장이 필요하다.
|
||||
- `ContentRankingItem.imageUrl`이 non-null `String`이므로 `coverImageUrl=null` 응답을 빈 문자열 또는 기존 placeholder용 값으로 안전하게 변환해야 한다.
|
||||
- `ContentRankingAdapter.submitItems()`는 현재 전체 갱신 방식이므로 DiffUtil 적용은 이번 범위에서 필수로 보지 않는다.
|
||||
- 응답 DTO와 위젯 item은 mapper로 분리해 DTO가 View 계층에 직접 노출되지 않도록 한다.
|
||||
|
||||
### Click Routing
|
||||
랭킹 item 클릭 시 오디오 콘텐츠 상세로 이동한다.
|
||||
|
||||
#### Requirements
|
||||
- `contentId > 0`인 item만 클릭 이벤트가 동작한다.
|
||||
- 오디오 콘텐츠 상세 이동은 기존 메인 콘텐츠 추천 탭의 `AudioContentDetailActivity` 이동 패턴을 재사용한다.
|
||||
- intent extra는 기존 `Constants.EXTRA_AUDIO_CONTENT_ID`를 사용한다.
|
||||
- 랭킹 item 클릭 시 상세 이동 외 별도 analytics/logging은 추가하지 않는다.
|
||||
|
||||
#### Edge Cases
|
||||
- `contentId <= 0`이면 클릭을 무시한다.
|
||||
- 빠른 연속 클릭 방지는 기존 화면 공통 정책이 있으면 따른다. 없으면 이번 범위에서 별도 debounce를 추가하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 8. UX / UI Expectations
|
||||
- 전체 배경은 black을 유지한다.
|
||||
- title-bar는 Figma 기준 `콘텐츠` 타이틀과 우측 cash/search/storage 아이콘을 유지한다.
|
||||
- 내부 Text Tab bar는 `추천`, `랭킹`, `전체`을 표시하고, 선택된 `랭킹`은 흰색 텍스트로 보여야 한다.
|
||||
- Capsule Tab bar는 높이 52dp 영역 안에서 가로 스크롤 가능해야 한다.
|
||||
- 랭킹 목록 좌우 여백은 Figma의 374px content 폭과 기존 `ContentRankingAdapter` 계산 정책에 맞춘다.
|
||||
- 카드 간 gap은 Figma와 기존 위젯 정책의 4dp 간격을 따른다.
|
||||
- 1위 카드는 넓은 대형 카드, 2~7위는 2열 정사각 카드, 8~10위는 3열 정사각 카드, 11위 이후는 가로형 카드로 표시한다.
|
||||
- 긴 제목/크리에이터명은 기존 `ContentRankingItem`의 rank별 말줄임 정책을 따른다.
|
||||
- 1위~20위까지 응답이 내려오면 Figma 예시처럼 자연스럽게 세로 스크롤로 확인할 수 있어야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Technical Constraints
|
||||
- Android XML Views, ViewBinding, RecyclerView 기반 기존 구조를 유지한다.
|
||||
- 신규 API DTO, Repository, ViewModel, mapper, UI state는 `kr.co.vividnext.sodalive.v2.main.content` 하위에 둔다.
|
||||
- 신규 `Activity`, `Fragment`, `ViewModel` 및 연결 하위 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
|
||||
- 기존 메인 콘텐츠 추천 탭 구현과 같은 `ApiResponse<T>`, RxJava `Single`, `BaseViewModel.compositeDisposable`, Koin 등록 패턴을 따른다.
|
||||
- JSON 매핑 annotation은 프로젝트의 Gson 관례에 맞춰 `@SerializedName`을 사용한다. 사용자 제공 DTO의 `@JsonProperty("isNew")`는 서버 계약 의미로만 기록하고, 실제 구현에서는 기존 Gson 설정과 충돌하지 않게 한다.
|
||||
- 네트워크/API 등록은 `AppDI.kt`의 `AudioRecommendationsApi`, `AudioRecommendationsRepository`, `ContentMainViewModel` 등록 위치와 일관되게 추가한다.
|
||||
- 레거시 패키지의 `AudioContentRankingAllActivity` 또는 기존 오디오 메인 화면 파일은 직접 수정하지 않는다.
|
||||
- 구현 전 `docs/20260623_메인_콘텐츠_탭_내부_랭킹_탭/plan-task.md`를 작성한 뒤 해당 계획에 따라 최소 구현한다.
|
||||
|
||||
---
|
||||
|
||||
## 10. Metrics
|
||||
- `랭킹` 탭 선택 시 랭킹 유형 Capsule Tab bar와 콘텐츠 랭킹 목록이 표시된다.
|
||||
- 초기 랭킹 유형은 `WEEKLY_POPULAR`/`주간 인기`로 선택된다.
|
||||
- 랭킹 유형 탭 6개가 Figma 순서대로 노출된다.
|
||||
- API 응답 item이 `ContentRankingAdapter`에 전달된다.
|
||||
- 1위, 2~7위, 8~10위, 11위 이후 variant가 기존 `ContentRankingPlacement` 정책과 일치한다.
|
||||
- `isNew=true` item은 New badge로 표시된다.
|
||||
- `isNew=false && (rankChange=null || rankChange=0)` item은 유지 상태로 표시된다.
|
||||
- `rankChange`가 양수/음수이면 상승/하락 상태와 절대값 숫자가 표시된다.
|
||||
- `showRankChange=false` 응답에서는 순위 변동 영역이 `GONE` 처리된다.
|
||||
- `contentId > 0` item 클릭 시 오디오 콘텐츠 상세로 이동한다.
|
||||
|
||||
---
|
||||
|
||||
## 11. Reusable V2 Widget Candidates
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TextTabBarView.kt`
|
||||
- 메인 콘텐츠 내부 상단 `추천`/`랭킹`/`전체` Text Tab bar에 재사용 가능하다.
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt`
|
||||
- 랭킹 유형 `주간 인기`/`지금 뜨는 중`/`매출`/`판매량`/`댓글수`/`좋아요` 가로 capsule tab에 재사용 가능하다.
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingAdapter.kt`
|
||||
- Figma와 같은 순위별 mixed grid/list RecyclerView adapter로 재사용 가능하다.
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItem.kt`
|
||||
- API item을 위젯 표시 계약으로 변환하는 목표 UI model로 재사용 가능하다.
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacement.kt`
|
||||
- rank별 `Large`/`MediumGrid`/`SmallGrid`/`Horizontal` 배치 정책이 Figma와 일치한다.
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentation.kt`
|
||||
- `New`, 상승, 하락, 유지 rank-num 표시 정책에 재사용 가능하다.
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt`
|
||||
- 1위 카드에 재사용 가능하다.
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingMediumGridCardView.kt`
|
||||
- 2~7위 카드에 재사용 가능하다.
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingSmallGridCardView.kt`
|
||||
- 8~10위 카드에 재사용 가능하다.
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt`
|
||||
- 11위 이후 가로형 카드에 재사용 가능하다.
|
||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
|
||||
- 기존 title-bar/Text Tab bar/추천 탭 구조와 오디오 상세 이동 패턴을 확장 대상으로 삼는다.
|
||||
- `app/src/main/res/layout/fragment_v2_main_content.xml`
|
||||
- 현재 콘텐츠 화면의 title-bar, Text Tab bar, 추천 content container 구조를 기준으로 랭킹 content container 추가를 검토한다.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open Questions
|
||||
- `coverImageUrl=null`일 때 사용할 정확한 placeholder asset이 별도로 지정되어 있는지 확인이 필요하다. 지정이 없으면 기존 이미지 로딩 정책을 따른다.
|
||||
Reference in New Issue
Block a user