Files
sodalive-android/docs/20260623_메인_콘텐츠_탭_내부_랭킹_탭/plan-task.md

38 KiB

메인 콘텐츠 탭 내부 랭킹 탭 구현 계획/TASK

For agentic workers: 구현 시 superpowers:subagent-driven-development 또는 superpowers:executing-plans를 사용해 task 단위로 진행한다. 각 단계는 체크박스(- [ ])로 추적하고, 완료 즉시 - [x]로 갱신한다. 구현 범위 변경이 생기면 이 문서를 먼저 수정한 뒤 코드에 반영한다.

Goal: GET /api/v2/audio/rankings?type={AudioRankingType} 응답을 기반으로 메인 콘텐츠 탭 내부 랭킹 탭에 오디오 콘텐츠 랭킹 목록을 표시한다.

Architecture: 기존 ContentMainFragmentfragment_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 또는 0RankingChangeType.Stay, amount 0으로 매핑한다.
  • rankChange=5RankingChangeType.Increase, amount 5로 매핑한다.
  • rankChange=-3RankingChangeType.Decrease, amount 3으로 매핑한다.
  • isNew=truerankChange보다 우선해 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_deltaGONE 처리한다.
  • 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: 기존 구조 확인과 작업 경계 고정

  • 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 등록 지점을 확인했다.
  • 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는 없고, bindDeltall_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 참조가 확인되어 확장 지점을 고정했다.
  • 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 숨김 계약 확장

  • Task 2.1: ContentRankingItem 표시 계약 테스트 추가

    • 수정:
      • app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItemTest.kt
    • 추가 테스트:
      • showRankChange 기본값은 true
      • showRankChange=false인 item은 순위 변동 표시 대상이 아님
      • 기존 isBlocked/isTouchable 동작은 유지
    • 구현 예시:
      @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 미구현 실패도 함께 확인되었다.
  • Task 2.2: ContentRankingItemshowRankChange 추가

    • 수정:
      • app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItem.kt
    • 구현:
      • 기존 생성자 마지막에 val showRankChange: Boolean = true를 추가한다.
      • 기본값을 둬 기존 호출부 변경을 최소화한다.
    • 코드 형태:
      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: ContentRankingItemshowRankChange: Boolean = true를 추가한 뒤 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*" 실행 결과 BUILD SUCCESSFUL을 확인했다.
  • Task 2.3: rank-num GONE view 테스트 추가

    • 생성:
      • app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingCardViewTest.kt
    • 테스트 케이스:
      • ContentRankingLargeCardViewshowRankChange=false item bind 시 ll_content_ranking_deltaGONE
      • ContentRankingMediumGridCardViewshowRankChange=false item bind 시 ll_content_ranking_deltaGONE
      • ContentRankingSmallGridCardViewshowRankChange=false item bind 시 ll_content_ranking_deltaGONE
      • ContentRankingHorizontalCardViewshowRankChange=false item bind 시 ll_content_ranking_deltaGONE
      • showRankChange=true item bind 시 ll_content_ranking_deltaVISIBLE
    • 구현 예시:
      @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=falsell_content_ranking_delta GONE, trueVISIBLE 계약을 검증하도록 했다. 기존 Phase 3 RED 테스트가 전체 unit-test 컴파일을 막아 card 테스트 단독 RED는 별도 분리 실행하지 못했고, 구현 후 widget 패키지 검증에서 함께 GREEN을 확인했다.
  • 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 적용을 유지한다.
    • 코드 형태:
      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, ContentRankingHorizontalCardViewbindDelta(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 작성

  • 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은 주간 인기, 지금 뜨는 중, 매출, 판매량, 댓글수, 좋아요 순서
    • 구현 예시:
      @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 미구현 상태를 확인했다.
  • Task 3.2: mapper RED 테스트 작성

    • 생성:
      • app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/AudioRankingsMapperTest.kt
    • 테스트 케이스:
      • 응답 item을 rank 오름차순으로 정렬한다.
      • rank < 1 item은 제외한다.
      • isNew=trueRankingChangeType.New, amount 0으로 매핑한다.
      • rankChange=null 또는 0RankingChangeType.Stay, amount 0으로 매핑한다.
      • rankChange=5RankingChangeType.Increase, amount 5로 매핑한다.
      • rankChange=-3RankingChangeType.Decrease, amount 3으로 매핑한다.
      • showRankChange=false이면 모든 ContentRankingItem.showRankChange=false로 매핑한다.
      • coverImageUrl=nullimageUrl=""로 매핑한다.
      • creatorId는 API에 없으므로 ContentRankingItem.creatorId=""로 매핑한다.
      • isBlocked=false로 매핑한다.
    • 구현 예시:
      @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 미구현 컴파일 실패를 확인한 뒤 같은 미구현 상태를 전제로 작성했다.
  • 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 응답 계약에 맞춰 AudioRankingResponsetype: AudioRankingType을 포함한다.
      • Gson @SerializedName("isNew")를 사용하고 Jackson @JsonProperty는 사용하지 않는다.
      • fun AudioRankingResponse.toContentRankingItems(): List<ContentRankingItem>를 추가한다.
      • fun AudioRankingType.labelResId(): Int 또는 label(context) helper는 UI 연결 Phase에서 문자열 리소스와 함께 확정한다.
    • 코드 형태:
      @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.ktAudioRankingsMappers.kt를 추가했다. ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*" 실행 결과 BUILD SUCCESSFUL.
      • 2026-06-24 리뷰 보완: 리뷰 게이트에서 PRD 응답 계약의 type 누락이 확인되어 AudioRankingResponse.type: AudioRankingType을 추가했다.
  • 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>>
    • 코드 형태:
      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.xmlview_content_ranking_type_tabs가 존재한다.
      • fragment_v2_main_content.xmlrv_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_rankingsview_content_ranking_type_tabs 아래부터 parent bottom까지 constraint하고 초기 gone으로 둔다.
      • 기존 nsv_content_recommendation_content는 추천 탭 선택 시만 visible로 유지한다.
    • 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.contentIdLong으로 변환한다.
      • contentId > 0이면 기존 openAudioContentDetail(audioContentId)를 호출한다.
      • 변환 실패 또는 contentId <= 0이면 아무 동작하지 않는다.
    • 코드 형태:
      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위 대형, 27위 2열, 810위 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. .editorconfigdisabled_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을 확인했다.