From 191b64d96ec4e7c64b0bd6a8dea3b9d0ee54b738 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 25 Jun 2026 01:09:20 +0900 Subject: [PATCH] =?UTF-8?q?docs(content):=20=EC=A0=84=EC=B2=B4=20=ED=83=AD?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EB=AC=B8=EC=84=9C=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 456 ++++++++++++++++++ .../prd.md | 295 +++++++++++ 2 files changed, 751 insertions(+) create mode 100644 docs/20260625_메인_콘텐츠_탭_내부_전체_탭/plan-task.md create mode 100644 docs/20260625_메인_콘텐츠_탭_내부_전체_탭/prd.md diff --git a/docs/20260625_메인_콘텐츠_탭_내부_전체_탭/plan-task.md b/docs/20260625_메인_콘텐츠_탭_내부_전체_탭/plan-task.md new file mode 100644 index 00000000..3604c0a4 --- /dev/null +++ b/docs/20260625_메인_콘텐츠_탭_내부_전체_탭/plan-task.md @@ -0,0 +1,456 @@ +# 메인 콘텐츠 탭 내부 전체 탭 구현 계획/TASK + +> **For agentic workers:** REQUIRED SUB-SKILL: 구현 시 `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`를 사용해 task 단위로 진행한다. 각 단계는 체크박스(`- [ ]`)로 추적하고, 완료 즉시 `- [x]`로 갱신한다. 구현 범위 변경이 생기면 이 문서를 먼저 수정한 뒤 코드에 반영한다. + +**Goal:** `GET /api/v2/audio/contents` 응답을 기반으로 메인 콘텐츠 탭 내부 `전체` 탭에 타입별 오디오/시리즈 콘텐츠 목록, 시리즈 요일 필터, 정렬, 스크롤 페이징을 제공한다. + +**Architecture:** 기존 `ContentMainFragment`의 `추천`, `랭킹`, `전체` Text Tab 구조는 유지하고, `전체` 선택 시 전용 Capsule type tab, optional day-of-week filter, sort bar, paged grid RecyclerView를 노출한다. 신규 API/Repository/DTO/UI state/mapper/ViewModel/adapter는 `kr.co.vividnext.sodalive.v2.main.content` 하위에 두고, 공통 카드 위젯인 `AudioContentCardView`, `SeriesContentCardView`, `CapsuleTabBarView`를 재사용한다. 레거시 파일은 직접 수정하지 않고 `SeriesPublishedDaysOfWeek` enum만 참조한다. + +**Tech Stack:** Kotlin, Android XML Views, ViewBinding, RecyclerView, Retrofit, Gson, RxJava3, Koin, JUnit4/Robolectric local unit test. + +--- + +## 전제와 성공 기준 +- PRD: `docs/20260625_메인_콘텐츠_탭_내부_전체_탭/prd.md` +- API endpoint는 `GET /api/v2/audio/contents`이다. +- query parameter 기본값은 `page=0`, `size=20`, `sort=LATEST`, `type=AUDIO`다. +- `dayOfWeek`는 `type=SERIES`일 때만 전송한다. +- 기본 `dayOfWeek`는 디바이스 현재 요일을 `SeriesPublishedDaysOfWeek`로 변환한다. +- UI 타입 탭은 `오디오`, `시리즈`, `오리지널`, `무료`, `포인트`만 구현한다. +- Figma에 보이는 `전체`, `연재` 콘텐츠 타입 칩은 이번 범위에서 구현하지 않는다. +- `SERIES`, `ORIGINAL`은 응답의 `series` 목록을 표시한다. +- `AUDIO`, `FREE`, `POINT`는 응답의 `audios` 목록을 표시한다. +- 이번 범위의 `ORIGINAL`은 오리지널 시리즈 목록만 의미한다. +- `RANDOM` 요일 라벨은 한국어 `기타`, 일본어 `その他`, 영어 `OTHER`로 표시한다. +- 정렬 옵션은 기존 `ContentSort.entries` 전체를 사용하고, UI는 `CreatorChannelSortPopup`/`ContentSort.toLabelResId()` 패턴을 따른다. +- `SeriesContentCardView`는 `isAdult` 성인 배지를 지원해야 한다. + - `SeriesContentCardSize.Large`: `ic_new_shield_large` + - `SeriesContentCardSize.Small`: `ic_new_shield_small` + - badge background: `bg_creator_channel_live_adult_badge` +- 구현 완료 후 최소 다음 명령을 실행한다. + - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"` + - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.SeriesContentCardViewTest"` + - `./gradlew :app:mergeDebugResources` + - `./gradlew :app:compileDebugKotlin` + - `./gradlew :app:ktlintCheck` + - `git diff --check` + +--- + +## Figma 참조 필요 Phase +- Phase 1: 제한 참조 + - 기존 콘텐츠 추천/랭킹 구조, DI, sort popup, 카드 위젯 구조 확인 중심으로 진행한다. +- Phase 2: Figma 참조 불필요 + - API/DTO/Repository와 enum/요일 변환은 PRD 서버 계약과 기존 V2 data layer 패턴을 따른다. +- Phase 3: 필수 참조 + - `SeriesContentCardView` 성인 배지는 Figma `567:18346`, `567:18347`과 기존 오디오 배지 구조를 대조한다. +- Phase 4: Figma 참조 불필요 + - ViewModel 페이징/정렬/요일 상태는 기존 크리에이터 채널 pagination 패턴을 따른다. +- Phase 5: 필수 참조 + - 전체 탭 type tab, 요일 필터, 정렬 바, 3열 그리드 위치는 Figma `35:5857`, `24:6909`, `24:9105`를 기준으로 확인한다. +- Phase 6: 필수 참조 + - 최종 수동 화면 검증은 PRD의 모든 포함/제외 항목과 실제 화면을 대조한다. + +--- + +## 파일 구조 +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/data/MainContentAllTabApi.kt` + - `GET /api/v2/audio/contents` Retrofit endpoint를 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/data/MainContentAllTabModels.kt` + - `MainContentAllTabResponse`, `MainContentAllType`, `MainContentAudioResponse`, `MainContentSeriesResponse` DTO를 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/data/MainContentAllTabRepository.kt` + - API 호출을 repository method로 감싼다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/MainContentAllTabUiState.kt` + - `Loading`, `Content`, `Empty`, `Error` 상태와 paging/loading-more 상태를 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/MainContentAllTabUiModels.kt` + - 오디오/시리즈/요일/type tab/sort UI model을 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/MainContentAllTabMappers.kt` + - DTO를 UI model로 변환하고 타입별 `audios`/`series` 선택 규칙을 구현한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/MainContentAllDayOfWeekMapper.kt` + - 디바이스 현재 요일과 UI 라벨을 `SeriesPublishedDaysOfWeek`로 매핑한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt` + - type/sort/day/page/hasNext/loading-more 상태와 API 호출을 관리한다. +- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt` + - `MainContentAllTabApi`, `MainContentAllTabRepository`, `ContentAllTabViewModel`을 Koin에 등록한다. +- Modify: `app/src/main/res/layout/view_series_content_card.xml` + - 성인 배지 `ImageView`를 썸네일 우측 상단에 추가한다. +- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardView.kt` + - `setAdultVisible(isVisible: Boolean)`과 size별 shield icon 적용을 추가한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardViewTest.kt` + - 성인 배지 visibility, background, size별 icon source를 검증한다. +- Create: `app/src/main/res/layout/item_content_all_series_card.xml` + - `SeriesContentCardView` 기반 전체 탭 시리즈 카드 item이다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllSeriesCardAdapter.kt` + - `SERIES`, `ORIGINAL` grid item을 바인딩한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllAudioCardAdapter.kt` + - `AUDIO`, `FREE`, `POINT` grid item을 바인딩한다. +- Modify: `app/src/main/res/layout/fragment_v2_main_content.xml` + - 전체 탭 전용 type tab, day filter, sort bar, grid RecyclerView, loading-more/error surface를 추가한다. +- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt` + - `전체` Text Tab 선택 시 전체 탭 surface를 표시하고 ViewModel/adapter/sort popup/day filter/paging/routing을 연결한다. +- Modify: `app/src/main/res/values/strings.xml` + - 전체 탭 type 라벨, 요일 `기타`, 정렬/빈 목록/오류 표시 문구를 추가한다. +- Modify: `app/src/main/res/values-en/strings.xml` + - 전체 탭 type 라벨, 요일 `OTHER`, 정렬/빈 목록/오류 표시 문구를 추가한다. +- Modify: `app/src/main/res/values-ja/strings.xml` + - 전체 탭 type 라벨, 요일 `その他`, 정렬/빈 목록/오류 표시 문구를 추가한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/MainContentAllTabMapperTest.kt` + - 타입별 audios/series 선택, tag/adult/original/free/point mapping을 검증한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/MainContentAllDayOfWeekMapperTest.kt` + - current day mapping과 라벨 리소스 mapping을 검증한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModelTest.kt` + - 첫 페이지, type/sort/day 변경, load-more, stale response 방지를 검증한다. +- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt` + - 전체 탭 layout id, adapter/ViewModel 연결, `전체`/`연재` 칩 제외, routing source를 검증한다. + +--- + +### Phase 1: 기존 구조 확인과 작업 경계 고정 + +- [ ] **Task 1.1: 기존 콘텐츠 탭 구조와 전체 탭 삽입 지점 확인** + - 확인: + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt` + - `app/src/main/res/layout/fragment_v2_main_content.xml` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainViewModel.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModel.kt` + - 작업: + - 기존 `추천`, `랭킹`, `전체` Text Tab bar는 유지한다. + - `showContentTab(CONTENT_TAB_ALL)` 분기에서 전체 탭 surface를 보이도록 확장할 위치를 확인한다. + - 추천/랭킹 API와 ViewModel은 리팩터링하지 않는다. + - 검증: + - Run: `rg -n "CONTENT_TAB_ALL|hideContentSurfaces|showRecommendationContent|showRankingContent|textTabBarContent" app/src/main/java/kr/co/vividnext/sodalive/v2/main/content app/src/main/res/layout/fragment_v2_main_content.xml` + - Expected: Text Tab 전환과 기존 surface visibility 지점이 확인된다. + +- [ ] **Task 1.2: 재사용 위젯과 신규 adapter 경계 확정** + - 확인: + - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardView.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelSortPopup.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelSortModels.kt` + - 작업: + - 타입 칩은 `CapsuleTabBarView` 재사용으로 고정한다. + - 정렬 팝업은 `CreatorChannelSortPopup`과 `ContentSort.toLabelResId()` 재사용으로 고정한다. + - 전체 탭 grid는 pagination과 type별 모델 분리를 위해 `ContentAllAudioCardAdapter`, `ContentAllSeriesCardAdapter` 신규 생성으로 고정한다. + - 검증: + - Run: `rg -n "class CapsuleTabBarView|class CreatorChannelSortPopup|fun ContentSort.toLabelResId|class AudioContentCardView|class SeriesContentCardView" app/src/main/java/kr/co/vividnext/sodalive/v2` + - Expected: 재사용 후보 클래스와 함수가 확인된다. + +- [ ] **Task 1.3: 제외 범위 확인** + - 확인: + - `docs/20260625_메인_콘텐츠_탭_내부_전체_탭/prd.md` + - 제외: + - Figma type chip `전체`, `연재` 구현 + - 추천/랭킹 탭 기존 기능 변경 + - 레거시 API/화면 파일 직접 수정 + - 오리지널 오디오 별도 표시 + - 오프라인 캐시/로컬 DB 저장 + - 검증: + - Run: `rg -n '전체|연재|Non-Goals|오리지널 오디오|레거시|Open Questions' docs/20260625_메인_콘텐츠_탭_내부_전체_탭/prd.md` + - Expected: 제외 범위와 Open Questions 없음 상태가 확인된다. + +--- + +### Phase 2: API, DTO, Repository, enum/요일 mapping 추가 + +- [ ] **Task 2.1: API/DTO/Repository 계약 추가** + - 생성: + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/data/MainContentAllTabApi.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/data/MainContentAllTabModels.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/data/MainContentAllTabRepository.kt` + - 수정: + - `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt` + - 작업: + - Retrofit endpoint는 `@GET("/api/v2/audio/contents")`로 정의한다. + - query parameter는 `type`, `sort`, `page`, `size`, `dayOfWeek`를 정의한다. + - `dayOfWeek` parameter는 nullable로 두고 repository/ViewModel에서 `SERIES`일 때만 값을 전달한다. + - DTO는 PRD response class 필드를 모두 포함하되 Gson `@SerializedName`을 사용한다. + - `MainContentAllType`은 `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`만 정의한다. + - `ContentSort`와 `SeriesPublishedDaysOfWeek`는 기존 타입을 import한다. + - Koin `networkModule`, `repositoryModule`에 신규 API/Repository를 등록한다. + - 검증: + - Run: `./gradlew :app:compileDebugKotlin` + - Expected: 신규 data layer와 DI 등록이 컴파일된다. + +- [ ] **Task 2.2: 타입 라벨과 요일 라벨 리소스 추가** + - 수정: + - `app/src/main/res/values/strings.xml` + - `app/src/main/res/values-en/strings.xml` + - `app/src/main/res/values-ja/strings.xml` + - 작업: + - type tab 라벨 문자열을 추가한다. + - `screen_content_all_type_audio` + - `screen_content_all_type_series` + - `screen_content_all_type_original` + - `screen_content_all_type_free` + - `screen_content_all_type_point` + - 전체 탭 요일 `RANDOM` 전용 문자열을 추가한다. + - `screen_content_all_day_other` + - values: `기타` + - values-en: `OTHER` + - values-ja: `その他` + - 기존 전역 `day_random`은 레거시 사용처 영향 방지를 위해 수정하지 않는다. + - 빈 목록/페이징 오류 표시를 위해 전체 탭 전용 문자열을 추가한다. + - `screen_content_all_empty` + - `screen_content_all_pagination_error` + - 검증: + - Run: `./gradlew :app:mergeDebugResources` + - Expected: 3개 locale string resource가 중복 없이 merge된다. + +- [ ] **Task 2.3: 요일 mapping 테스트 작성** + - 생성: + - `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/MainContentAllDayOfWeekMapperTest.kt` + - 테스트 케이스: + - `Calendar.MONDAY`는 `SeriesPublishedDaysOfWeek.MON` + - `Calendar.TUESDAY`는 `SeriesPublishedDaysOfWeek.TUE` + - `Calendar.WEDNESDAY`는 `SeriesPublishedDaysOfWeek.WED` + - `Calendar.THURSDAY`는 `SeriesPublishedDaysOfWeek.THU` + - `Calendar.FRIDAY`는 `SeriesPublishedDaysOfWeek.FRI` + - `Calendar.SATURDAY`는 `SeriesPublishedDaysOfWeek.SAT` + - `Calendar.SUNDAY`는 `SeriesPublishedDaysOfWeek.SUN` + - `SeriesPublishedDaysOfWeek.RANDOM` 라벨은 `screen_content_all_day_other` + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.MainContentAllDayOfWeekMapperTest"` + - Expected: mapper 구현 전 RED 실패. + +- [ ] **Task 2.4: 요일 mapping 구현** + - 생성: + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/MainContentAllDayOfWeekMapper.kt` + - 작업: + - `fun currentDeviceDayOfWeek(calendar: Calendar = Calendar.getInstance()): SeriesPublishedDaysOfWeek`를 추가한다. + - `fun SeriesPublishedDaysOfWeek.toContentAllDayLabelResId(): Int`를 추가한다. + - `RANDOM`은 `R.string.screen_content_all_day_other`로 매핑한다. + - 요일 UI 표시 순서는 `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `SUN`, `RANDOM`으로 고정한다. + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.MainContentAllDayOfWeekMapperTest"` + - Expected: PASS. + +--- + +### Phase 3: UI model, mapper, SeriesContentCardView 성인 배지 + +- [ ] **Task 3.1: 전체 탭 mapper RED 테스트 작성** + - 생성: + - `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/MainContentAllTabMapperTest.kt` + - 테스트 케이스: + - `AUDIO`, `FREE`, `POINT`는 response `audios`만 UI 목록으로 사용한다. + - `SERIES`, `ORIGINAL`은 response `series`만 UI 목록으로 사용한다. + - `ORIGINAL`은 시리즈 카드 타입으로 매핑한다. + - `isAdult`는 오디오/시리즈 UI model의 `showAdultBadge`로 매핑된다. + - 오디오 `isPointAvailable=true`는 `AudioContentTag.Point`로 매핑된다. + - 오디오 `isFirstContent=true`는 `AudioContentTag.First`로 매핑된다. + - 오디오 `isOriginalSeries=true`는 `AudioContentTag.Original`로 매핑된다. + - 오디오 `price == 0`은 `AudioContentTag.Free`로 매핑된다. + - `hasNext`, `page`, `size`, `totalCount`, `sort`, `dayOfWeek`는 UI state에 보존된다. + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.MainContentAllTabMapperTest"` + - Expected: UI model/mapper 구현 전 RED 실패. + +- [ ] **Task 3.2: 전체 탭 UI model과 mapper 구현** + - 생성: + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/MainContentAllTabUiState.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/MainContentAllTabUiModels.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/MainContentAllTabMappers.kt` + - 작업: + - `MainContentAllAudioUiModel`은 `audioContentId`, `title`, `imageUrl`, `price`, `creatorNickname`, `tags`, `showAdultBadge`를 가진다. + - `MainContentAllSeriesUiModel`은 `seriesId`, `title`, `coverImageUrl`, `creatorNickname`, `showOriginalTag`, `showAdultBadge`를 가진다. + - `MainContentAllTabUiState.Content`는 `selectedType`, `selectedSort`, `selectedDayOfWeek`, `totalCount`, `audioItems`, `seriesItems`, `page`, `size`, `hasNext`, `isLoadingMore`, `paginationErrorMessage`를 가진다. + - `MainContentAllType.usesSeriesItems()`는 `SERIES`, `ORIGINAL`에서 true를 반환한다. + - `MainContentAllType.usesDayOfWeekQuery()`는 `SERIES`에서만 true를 반환한다. + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.MainContentAllTabMapperTest"` + - Expected: PASS. + +- [ ] **Task 3.3: SeriesContentCardView 성인 배지 테스트 작성** + - 생성: + - `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardViewTest.kt` + - 테스트 케이스: + - inflate 직후 adult badge는 `GONE` + - `setAdultVisible(true)` 호출 시 adult badge는 `VISIBLE` + - `setAdultVisible(false)` 호출 시 adult badge는 `GONE` + - `setSize(SeriesContentCardSize.Large)` 호출 시 adult badge는 24dp 컨테이너와 `ic_new_shield_large`를 사용한다. + - `setSize(SeriesContentCardSize.Small)` 호출 시 adult badge는 18dp 컨테이너와 `ic_new_shield_small`을 사용한다. + - adult badge background는 `bg_creator_channel_live_adult_badge`다. + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.SeriesContentCardViewTest"` + - Expected: adult badge 미구현으로 RED 실패. + +- [ ] **Task 3.4: SeriesContentCardView 성인 배지 구현** + - 수정: + - `app/src/main/res/layout/view_series_content_card.xml` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardView.kt` + - 작업: + - `view_series_content_card.xml`의 `FrameLayout` 안에 `ImageView @+id/iv_series_content_adult_badge`를 추가한다. + - layout gravity는 `top|end`, 기본 visibility는 `gone`으로 둔다. + - background는 `@drawable/bg_creator_channel_live_adult_badge`로 둔다. + - `SeriesContentCardView`에서 `adultBadge`를 findViewById로 보관한다. + - `setAdultVisible(isVisible: Boolean)`을 추가한다. + - `setSize(size)`에서 size별 adult badge layout params와 icon을 갱신한다. + - Large: 24dp, marginTop/marginEnd 8dp, padding 4dp, `ic_new_shield_large` + - Small: 18dp, marginTop/marginEnd 6dp, padding 2dp, `ic_new_shield_small` + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.SeriesContentCardViewTest"` + - Expected: PASS. + +--- + +### Phase 4: ViewModel 페이징, 타입, 정렬, 요일 상태 구현 + +- [ ] **Task 4.1: ViewModel RED 테스트 작성** + - 생성: + - `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModelTest.kt` + - 테스트 케이스: + - 최초 로드는 `type=AUDIO`, `sort=LATEST`, `page=0`, `size=20`, `dayOfWeek=null`로 요청한다. + - `SERIES` 선택 시 현재 디바이스 요일을 포함해 `page=0`으로 요청한다. + - `AUDIO`, `FREE`, `POINT`, `ORIGINAL` 선택 시 `dayOfWeek=null`로 요청한다. + - `SERIES` 상태에서 요일 변경 시 `type=SERIES`, 변경된 `dayOfWeek`, `page=0`으로 요청한다. + - 정렬 변경 시 현재 type/day 조건을 유지하고 `page=0`으로 요청한다. + - `hasNext=true`이면 `loadMore()`가 다음 page를 요청하고 기존 목록 뒤에 append한다. + - loading-more 중복 요청은 1회로 제한한다. + - load-more 실패 시 기존 목록을 유지하고 `paginationErrorMessage`를 설정한다. + - type/sort/day 변경 후 도착한 이전 응답은 현재 목록에 append하지 않는다. + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest"` + - Expected: ViewModel 미구현으로 RED 실패. + +- [ ] **Task 4.2: ContentAllTabViewModel 구현** + - 생성: + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt` + - 작업: + - `DEFAULT_PAGE_SIZE = 20`, `FIRST_PAGE = 0`을 정의한다. + - `allTabStateLiveData`, `isLoading`, `toastLiveData`를 노출한다. + - `loadInitial()`, `changeType(type)`, `changeSort(sort)`, `changeDayOfWeek(dayOfWeek)`, `loadMore()`, `retry()`, `consumePaginationErrorMessage()`를 구현한다. + - `requestGeneration` 방식으로 stale response를 무시한다. + - `authToken()`은 기존 ViewModel과 동일하게 `Bearer ${SharedPreferenceManager.token}` 형태를 사용한다. + - API success + 표시 대상 list empty이면 `Empty` 상태로 둔다. + - first page error는 `Error` 상태와 `R.string.common_error_unknown` toast로 처리한다. + - load-more error는 기존 content state를 유지하고 `paginationErrorMessage`만 설정한다. + - 수정: + - `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt` + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest"` + - Expected: PASS. + +--- + +### Phase 5: Layout, adapter, Fragment 연결 + +- [ ] **Task 5.1: 전체 탭 layout과 source test 추가** + - 수정: + - `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt` + - 테스트 케이스: + - `fragment_v2_main_content.xml`에 `view_content_all_type_tabs`가 있다. + - `fragment_v2_main_content.xml`에 `layout_content_all_day_filter`가 있다. + - `fragment_v2_main_content.xml`에 `layout_content_all_sort_bar`가 있다. + - `fragment_v2_main_content.xml`에 `rv_content_all_items`가 있다. + - source에 `ContentAllTabViewModel by viewModel()`이 있다. + - source에 `ContentAllAudioCardAdapter`와 `ContentAllSeriesCardAdapter`가 있다. + - source에 `MainContentAllType.AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`는 있지만 type tab menu에 `전체`, `연재` 매핑은 없다. + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"` + - Expected: layout/Fragment 연결 전 RED 실패. + +- [ ] **Task 5.2: 전체 탭 item layout과 adapter 구현** + - 생성: + - `app/src/main/res/layout/item_content_all_series_card.xml` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllAudioCardAdapter.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllSeriesCardAdapter.kt` + - 작업: + - 오디오 adapter는 `AudioContentCardView`에 `AudioContentCardSize.Small`을 적용한다. + - 오디오 adapter는 title/creator/image/tags/adult badge를 바인딩하고 클릭 시 `audioContentId`를 전달한다. + - 시리즈 adapter는 `SeriesContentCardView`에 `SeriesContentCardSize.Small`을 적용한다. + - 시리즈 adapter는 title/creator/cover/original/adult badge를 바인딩하고 클릭 시 `seriesId`를 전달한다. + - adapter는 `submitItems(items)`로 전체 list를 교체하며, ViewModel이 append된 list를 전달한다. + - 검증: + - Run: `./gradlew :app:mergeDebugResources` + - Expected: 신규 item layout binding class가 생성된다. + - Run: `./gradlew :app:compileDebugKotlin` + - Expected: 신규 adapter가 컴파일된다. + +- [ ] **Task 5.3: fragment_v2_main_content.xml에 전체 탭 surface 추가** + - 수정: + - `app/src/main/res/layout/fragment_v2_main_content.xml` + - 작업: + - `text_tab_bar_content` 아래에 `view_content_all_type_tabs` include를 추가한다. + - `layout_content_all_day_filter`를 추가하고 기본 visibility는 `gone`으로 둔다. + - `layout_content_all_sort_bar`를 추가하고 total count와 sort label view id를 둔다. + - `tv_content_all_total_count` + - `layout_content_all_sort_button` + - `tv_content_all_sort_label` + - `rv_content_all_items` RecyclerView를 추가하고 기본 visibility는 `gone`으로 둔다. + - 기존 추천/랭킹 surface와 겹치지 않도록 `showContentTab()`에서 visibility를 제어할 수 있는 root id를 둔다. + - `layout_content_all_surface` + - 검증: + - Run: `./gradlew :app:mergeDebugResources` + - Expected: layout resource merge가 성공한다. + +- [ ] **Task 5.4: ContentMainFragment 전체 탭 연결** + - 수정: + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt` + - 작업: + - `private val contentAllTabViewModel: ContentAllTabViewModel by viewModel()`을 추가한다. + - type tab은 `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT` 순서로 구성한다. + - `CONTENT_TAB_ALL` 선택 시 추천/랭킹 surface를 숨기고 전체 탭 surface를 표시한다. + - 전체 탭 최초 선택 시 `contentAllTabViewModel.loadInitial()`을 한 번 호출한다. + - type 변경 시 `contentAllTabViewModel.changeType(...)`을 호출한다. + - `SERIES` 상태에서만 day filter를 보이고, 다른 type에서는 숨긴다. + - day filter 클릭 시 `changeDayOfWeek(...)`를 호출한다. + - sort button 클릭 시 `CreatorChannelSortPopup`을 띄우고 `changeSort(...)`를 호출한다. + - grid는 `GridLayoutManager(spanCount = 3)`로 구성한다. + - 현재 state가 오디오 계열이면 audio adapter를, 시리즈 계열이면 series adapter를 연결한다. + - RecyclerView 하단 접근 시 `loadMore()`를 호출한다. + - `paginationErrorMessage`는 toast로 표시하고 `consumePaginationErrorMessage()`를 호출한다. + - 오디오 클릭은 기존 `openAudioContentDetail(audioContentId)`를 재사용한다. + - 시리즈 클릭은 기존 `openSeriesDetail(seriesId)`를 재사용한다. + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"` + - Expected: PASS. + - Run: `./gradlew :app:compileDebugKotlin` + - Expected: Fragment/ViewBinding/adapter 연결이 컴파일된다. + +--- + +### Phase 6: 통합 검증과 수동 확인 + +- [ ] **Task 6.1: 단위 테스트 실행** + - 실행: + - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"` + - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.SeriesContentCardViewTest"` + - 기대 결과: + - 전체 탭 mapper/day/ViewModel/source test가 PASS한다. + - 시리즈 카드 성인 배지 test가 PASS한다. + - 검증 기록: + - 구현 후 실행 결과를 여기에 누적한다. + +- [ ] **Task 6.2: 리소스/컴파일/스타일 검증** + - 실행: + - `./gradlew :app:mergeDebugResources` + - `./gradlew :app:compileDebugKotlin` + - `./gradlew :app:ktlintCheck` + - `git diff --check` + - 기대 결과: + - resource merge, Kotlin compile, ktlint, whitespace 검증이 모두 성공한다. + - 검증 기록: + - 구현 후 실행 결과를 여기에 누적한다. + +- [ ] **Task 6.3: 수동 화면 검증** + - 확인: + - 콘텐츠 탭 진입 후 `추천`이 기존처럼 표시된다. + - `랭킹`이 기존처럼 표시된다. + - `전체` 선택 시 오디오 type이 기본 선택된다. + - type chip에는 `오디오`, `시리즈`, `오리지널`, `무료`, `포인트`만 보인다. + - type chip에 Figma의 `전체`, `연재`는 보이지 않는다. + - `AUDIO`, `FREE`, `POINT`는 오디오 카드 3열 grid로 표시된다. + - `SERIES`, `ORIGINAL`은 시리즈 카드 3열 grid로 표시된다. + - `SERIES`에서만 요일 필터가 보인다. + - `RANDOM` 요일은 한국어 `기타`, 일본어 `その他`, 영어 `OTHER`로 표시된다. + - sort popup은 기존 `ContentSort` UI와 동일한 선택 UI로 동작한다. + - 하단 스크롤 시 다음 페이지가 append된다. + - 시리즈 성인 콘텐츠는 `SeriesContentCardView` 우측 상단에 성인 배지를 표시한다. + - 검증 기록: + - 구현 후 수동 확인 결과를 여기에 누적한다. + +--- + +## Verification Log +- 문서 작성 시점에는 구현 전이므로 실행한 빌드/테스트가 없다. diff --git a/docs/20260625_메인_콘텐츠_탭_내부_전체_탭/prd.md b/docs/20260625_메인_콘텐츠_탭_내부_전체_탭/prd.md new file mode 100644 index 00000000..b435af7b --- /dev/null +++ b/docs/20260625_메인_콘텐츠_탭_내부_전체_탭/prd.md @@ -0,0 +1,295 @@ +# PRD: 메인 콘텐츠 탭 내부 전체 탭 + +## 1. Overview +메인 콘텐츠 화면의 내부 `전체` 탭에서 콘텐츠 타입별 목록을 `GET /api/v2/audio/contents` API로 조회하고, Figma 디자인에 맞춰 오디오/시리즈 카드를 페이징 목록으로 제공한다. + +작성일: 2026-06-25 + +--- + +## 2. Problem +- 현재 메인 콘텐츠 화면의 내부 `전체` 탭에 연결할 신규 V2 API 계약과 UI 상태별 데이터 표시 규칙이 문서화되어 있지 않다. +- 콘텐츠 타입에 따라 응답 필드가 `audios` 또는 `series`로 나뉘며, 타입별로 카드 UI와 필터 노출 조건이 다르다. +- 시리즈 타입에서는 요일 필터가 필요하고, 기본 요일은 디바이스의 현재 요일을 따라야 한다. + +--- + +## 3. Goals +- 내부 `전체` 탭 진입 시 기본 콘텐츠 타입 `AUDIO`를 선택하고 첫 페이지를 조회한다. +- 콘텐츠 타입 탭 선택 시 `type`, `sort`, `page`, `size`, `dayOfWeek` query parameter 규칙에 맞게 API를 호출한다. +- 스크롤 페이징을 적용해 `hasNext = true`인 동안 다음 페이지를 이어서 조회한다. +- `SERIES`, `ORIGINAL` 선택 시 응답의 `series` 목록을 표시하고, `AUDIO`, `FREE`, `POINT` 선택 시 응답의 `audios` 목록을 표시한다. +- 이번 범위의 `ORIGINAL`은 오리지널 시리즈 목록만 의미한다. +- `SERIES` 타입에서만 요일 UI를 노출하고 `dayOfWeek` query parameter를 함께 전송한다. +- Figma 디자인의 오디오, 시리즈, 오리지널 상태를 기준으로 기존 V2 위젯 재사용 후보를 우선 검토한다. + +--- + +## 4. Non-Goals +- 추천 탭, 랭킹 탭의 기존 기능 변경은 제외한다. +- 레거시 화면 또는 레거시 API 파일을 직접 수정하지 않는다. 필요한 레거시 타입은 참조만 한다. +- API 계약에 없는 별도 콘텐츠 타입을 새로 만들지 않는다. +- 상세 화면, 결제, 보관함, 검색, 정렬 팝업의 신규 기능 확장은 제외한다. +- 오리지널 오디오를 `ORIGINAL` 타입에서 별도로 표시하는 동작은 제외한다. +- 오프라인 캐시 또는 로컬 DB 저장은 이번 범위에서 제외한다. + +--- + +## 5. Target Users +- 메인 콘텐츠 탭에서 최신 오디오 콘텐츠를 탐색하는 사용자 +- 시리즈/오리지널/무료/포인트 콘텐츠를 타입별로 빠르게 전환해 탐색하는 사용자 +- 특정 요일에 연재되는 시리즈를 확인하려는 사용자 + +--- + +## 6. User Stories +- 사용자는 콘텐츠 화면에서 `전체` 탭을 선택하면 기본적으로 오디오 콘텐츠 목록을 보고 싶다. +- 사용자는 콘텐츠 타입 탭에서 `시리즈`를 선택하면 시리즈 카드 목록과 요일 필터를 보고 싶다. +- 사용자는 요일을 선택하면 해당 요일에 맞는 시리즈 목록으로 갱신되길 원한다. +- 사용자는 목록 하단으로 스크롤하면 다음 콘텐츠가 자연스럽게 이어서 로드되길 원한다. +- 사용자는 `오리지널`을 선택하면 오리지널 시리즈 목록을 보고 싶다. +- 사용자는 `무료`, `포인트`를 선택하면 오디오 카드 UI와 동일한 형태로 콘텐츠를 보고 싶다. + +--- + +## 7. Core Features + +### Feature A. 콘텐츠 타입별 API 조회 +#### Requirements +- API endpoint는 `GET /api/v2/audio/contents`를 사용한다. +- query parameter: + - `type`: `MainContentAllType` + - `sort`: `ContentSort` + - `page`: `Int` + - `size`: `Int` + - `dayOfWeek`: `SeriesPublishedDaysOfWeek` +- query parameter 기본값: + - `page = 0` + - `size = 20` + - `sort = LATEST` + - `type = AUDIO` + - `dayOfWeek = 현재 디바이스에 설정된 요일` +- `dayOfWeek`는 `type == SERIES`일 때만 전송한다. +- `dayOfWeek` 타입은 기존 `kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek`를 사용한다. +- `sort` 타입은 기존 `kr.co.vividnext.sodalive.v2.common.data.ContentSort`를 우선 재사용한다. +- 신규 API/Repository/DTO/ViewModel 등 구현이 필요하면 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다. + +#### Response Data Class +```kotlin +data class MainContentAllTabResponse( + val type: MainContentAllType, + val totalCount: Int, + val audios: List, + val series: List, + val sort: ContentSort, + val dayOfWeek: SeriesPublishedDaysOfWeek?, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) + +enum class MainContentAllType { + AUDIO, + SERIES, + ORIGINAL, + FREE, + POINT +} + +data class MainContentAudioResponse( + val audioContentId: Long, + val title: String, + val imageUrl: String?, + val price: Int, + @JsonProperty("isAdult") + val isAdult: Boolean, + @JsonProperty("isPointAvailable") + val isPointAvailable: Boolean, + @JsonProperty("isFirstContent") + val isFirstContent: Boolean, + @JsonProperty("isOriginalSeries") + val isOriginalSeries: Boolean, + val creatorNickname: String +) + +data class MainContentSeriesResponse( + val seriesId: Long, + val title: String, + val coverImageUrl: String?, + val creatorNickname: String, + @JsonProperty("isOriginal") + val isOriginal: Boolean, + @JsonProperty("isAdult") + val isAdult: Boolean +) +``` + +#### Edge Cases +- 첫 페이지 응답이 비어 있으면 빈 목록 상태를 표시한다. +- 다음 페이지 요청 중 콘텐츠 타입, 정렬, 요일이 변경되면 기존 페이지 요청 상태를 초기화하고 `page = 0`부터 다시 조회한다. +- `hasNext = false`이면 추가 페이지를 요청하지 않는다. +- API 응답의 `dayOfWeek`가 null이어도 `SERIES` 타입이 아니면 정상으로 처리한다. +- `SERIES` 타입에서 `series`가 비어 있고 `audios`가 내려와도 UI에는 `series`만 표시한다. +- `AUDIO`, `FREE`, `POINT` 타입에서 `audios`가 비어 있고 `series`가 내려와도 UI에는 `audios`만 표시한다. + +--- + +### Feature B. 콘텐츠 타입 탭 +#### Requirements +- UI의 콘텐츠 타입 기본값은 `오디오`이며 API 기본 `type = AUDIO`와 일치해야 한다. +- 타입 탭은 최소 아래 타입을 제공한다. + - 오디오: `AUDIO` + - 시리즈: `SERIES` + - 오리지널: `ORIGINAL` + - 무료: `FREE` + - 포인트: `POINT` +- Figma에 `전체`, `연재` 칩이 보이더라도 이번 범위에서는 구현하지 않고, 본 PRD에 정의된 API 계약의 `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`만 연동한다. +- 타입 변경 시 목록, 페이지 번호, `hasNext`, 로딩 상태를 새 타입 기준으로 초기화한다. +- `무료`와 `포인트`는 오디오 선택 UI와 동일한 카드 레이아웃을 사용한다. +- `ORIGINAL`은 이번 범위에서 `series` 목록만 의미하며, 시리즈 카드 레이아웃으로 표시한다. + +#### Edge Cases +- 빠르게 탭을 연속 선택해도 마지막으로 선택한 타입의 데이터만 화면에 남아야 한다. +- 타입 변경 직후 이전 타입의 페이징 요청이 완료되어도 현재 타입 목록에 섞이면 안 된다. + +--- + +### Feature C. 시리즈 요일 필터 +#### Requirements +- 요일 UI는 `SERIES` 타입 선택 시에만 노출한다. +- 기본 선택 요일은 디바이스의 현재 요일을 `SeriesPublishedDaysOfWeek`로 변환해 사용한다. +- 요일 목록은 `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `SUN`, `RANDOM`을 제공한다. +- 이번 디자인에서 `RANDOM`은 언어별로 아래처럼 표시한다. + - 한국어: `기타` + - 일본어: `その他` + - 영어: `OTHER` +- 요일 변경 시 `type = SERIES`, `page = 0`, 선택한 `dayOfWeek`로 목록을 다시 조회한다. + +#### Edge Cases +- 디바이스 날짜/요일을 읽을 수 없는 예외 상황에서는 `RANDOM`을 fallback으로 사용할 수 있다. +- `SERIES`가 아닌 타입으로 전환하면 요일 UI를 숨기고 API 요청에서 `dayOfWeek`를 제거한다. + +--- + +### Feature D. 목록 표시와 스크롤 페이징 +#### Requirements +- 내부 `전체` 탭의 콘텐츠 목록은 스크롤 가능한 그리드로 표시한다. +- 첫 페이지 로딩, 추가 페이지 로딩, 빈 목록, 에러 상태를 구분한다. +- `hasNext = true`이고 사용자가 목록 하단에 접근하면 다음 `page`를 요청한다. +- 추가 페이지 응답은 기존 목록 뒤에 append한다. +- `totalCount`는 Figma의 `전체 23` 영역처럼 전체 개수 표시가 필요한 경우 사용한다. +- 정렬 기본값은 `ContentSort.LATEST`이며 화면에는 기존 최신순 문자열을 사용한다. +- 정렬 옵션은 기존 `ContentSort` 값을 모두 사용한다. + - `LATEST` + - `POPULAR` + - `OWNED` + - `PRICE_HIGH` + - `PRICE_LOW` +- 정렬 UI는 기존 `ContentSort` 사용 화면과 동일한 팝업/선택 UI 패턴을 따른다. + - 기존 후보: `CreatorChannelSortPopup`, `ContentSort.toLabelResId()` +- 정렬 변경 시 `page = 0`부터 현재 선택된 `type`, `dayOfWeek` 조건으로 다시 조회한다. + +#### Edge Cases +- 추가 페이지 로딩 중 중복 요청을 방지한다. +- 추가 페이지 실패 시 기존 목록은 유지하고 재시도 가능한 상태를 제공한다. +- 첫 페이지 실패 시 기존 콘텐츠 탭의 토스트/에러 처리 관례를 따른다. + +--- + +### Feature E. 타입별 카드 바인딩 +#### Requirements +- `AUDIO`, `FREE`, `POINT` 선택 시 `audios` 데이터를 오디오 카드에 바인딩한다. + - `audioContentId`: 상세 이동에 사용 + - `title`: 제목 + - `imageUrl`: 썸네일 + - `creatorNickname`: 크리에이터명 + - `isAdult`: 성인 배지 표시 + - `isPointAvailable`: 포인트 태그 표시 + - `isFirstContent`: FIRST 태그 표시 + - `isOriginalSeries`: 오리지널 태그 표시 + - `price`: 무료 여부 판단이 필요한 경우 사용 +- `SERIES`, `ORIGINAL` 선택 시 `series` 데이터를 시리즈 카드에 바인딩한다. + - `seriesId`: 상세 이동에 사용 + - `title`: 제목 + - `coverImageUrl`: 커버 이미지 + - `creatorNickname`: 크리에이터명 + - `isOriginal`: 오리지널 태그 표시 + - `isAdult`: 성인 배지 표시 +- `SeriesContentCardView`에 `isAdult` 표시 기능을 추가한다. + - 오디오 콘텐츠의 `isAdult` 표시와 동일하게 썸네일 우측 상단에 성인 배지를 표시한다. + - 배경은 `bg_creator_channel_live_adult_badge`를 사용한다. + - `SeriesContentCardSize.Large`에서는 `ic_new_shield_large`를 사용한다. + - `SeriesContentCardSize.Small`에서는 `ic_new_shield_small`을 사용한다. + - `isAdult = true`이면 표시하고, `isAdult = false`이면 숨긴다. + +#### Edge Cases +- 이미지 URL이 null이면 기존 이미지 로딩 관례의 placeholder/error 처리를 따른다. +- 제목/크리에이터명은 한 줄 ellipsize로 표시해 카드 높이가 흔들리지 않게 한다. + +--- + +## 8. UX / UI Expectations +- 기준 디자인: + - 오디오 선택 상태: Figma node `35:5857` + - 시리즈 선택 상태: Figma node `24:6909` + - 오리지널 선택 상태: Figma node `24:9105` + - 성인 배지 Large 태그: Figma node `567:18346` + - 성인 배지 Small 태그: Figma node `567:18347` +- 화면 상단은 기존 콘텐츠 타이틀 바와 내부 텍스트 탭(`추천`, `랭킹`, `전체`) 구조를 유지한다. +- 내부 `전체` 탭 선택 시 콘텐츠 타입 칩 영역을 표시한다. +- 오디오/무료/포인트는 정사각형 썸네일 카드 3열 그리드 형태를 따른다. +- 시리즈/오리지널은 세로형 커버 카드 3열 그리드 형태를 따른다. +- 시리즈 선택 상태에서만 요일 필터가 콘텐츠 타입 칩과 정렬 바 사이에 표시된다. +- 선택된 칩은 흰색 배경/검정 텍스트, 비선택 칩은 검정 배경/회색 테두리/흰색 텍스트 스타일을 따른다. +- 정렬 바는 왼쪽에 `전체 {totalCount}`, 오른쪽에 `최신순`을 표시한다. +- 한국어 요일 UI의 `RANDOM` 표기는 `기타`로 표시한다. +- 일본어 요일 UI의 `RANDOM` 표기는 `その他`로 표시한다. +- 영어 요일 UI의 `RANDOM` 표기는 `OTHER`로 표시한다. +- 시리즈 카드 성인 배지는 Figma Large/Small 태그 크기와 기존 오디오 성인 배지 표현을 따른다. + +### 재사용 가능한 V2 위젯 후보 +- `kr.co.vividnext.sodalive.v2.widget.CapsuleTabBarView` + - 콘텐츠 타입 칩 탭에 재사용 가능하다. +- `kr.co.vividnext.sodalive.v2.widget.TextTabBarView` + - 기존 내부 텍스트 탭(`추천`, `랭킹`, `전체`)에 이미 사용 중이다. +- `kr.co.vividnext.sodalive.v2.widget.AudioContentCardView` + - `AUDIO`, `FREE`, `POINT` 카드에 재사용 가능하다. + - Figma의 3열 그리드에는 `AudioContentCardSize.Small` 후보가 적합하다. +- `kr.co.vividnext.sodalive.v2.widget.SeriesContentCardView` + - `SERIES`, `ORIGINAL` 카드에 재사용 가능하다. + - Figma의 3열 그리드에는 `SeriesContentCardSize.Small` 후보가 적합하다. + - `isAdult` 표시를 위해 성인 배지 View와 표시 제어 API를 추가해야 한다. +- `kr.co.vividnext.sodalive.v2.main.content.ui.ContentAudioCardAdapter` + - 기존 어댑터가 오디오 카드 바인딩 패턴을 제공하므로 신규 전체 탭 어댑터 작성 시 참고하거나 확장 후보로 검토한다. +- `kr.co.vividnext.sodalive.v2.main.content.ui.ContentOriginalSeriesAdapter` + - 기존 오리지널 시리즈 바인딩 패턴 참고 후보이나, 현재는 전용 레이아웃 기반이므로 전체 탭의 3열 그리드에는 `SeriesContentCardView` 기반 신규 어댑터가 더 적합할 수 있다. +- `kr.co.vividnext.sodalive.v2.main.content.ui.addContentGridItemSpacing` + - 3열 그리드 간격 재사용 후보로 검토한다. + +--- + +## 9. Technical Constraints +- Android Gradle 단일 모듈 `:app`에서 작업한다. +- 모든 명령은 저장소 루트에서 실행한다. +- 신규 구현은 `kr.co.vividnext.sodalive.v2` 패키지 하위에 둔다. +- 레거시 코드는 직접 수정하지 않고, 필요한 경우 기존 enum 또는 화면 이동 API를 참조한다. +- DI 추가가 필요하면 기존 `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`의 Koin 패턴을 따른다. +- 응답 처리는 기존 `ApiResponse`와 Rx 타입(`Single`, `Flowable`) 패턴을 우선 검토한다. +- 공개 API 스키마는 사용자 요청에 명시된 계약을 그대로 따른다. +- `BuildConfig` 값이나 민감 정보는 로그/Toast/크래시 메시지에 노출하지 않는다. + +--- + +## 10. Metrics +- 내부 `전체` 탭 첫 페이지 API 성공률 +- 콘텐츠 타입별 탭 클릭 수 +- 시리즈 요일 필터 클릭 수 +- 페이징 추가 로드 성공률과 실패율 +- 첫 페이지 로딩 시간과 추가 페이지 로딩 시간 +- 빈 목록 노출 빈도 + +--- + +## 11. Open Questions +- 없음.