From e2d46614bd232e354e5b9f3682e354eecf3cbee5 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 30 Jun 2026 21:24:31 +0900 Subject: [PATCH] =?UTF-8?q?docs(home):=20=ED=8C=94=EB=A1=9C=EC=9E=89=20?= =?UTF-8?q?=ED=83=AD=20=EA=B3=84=ED=9A=8D=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260630_메인_홈_팔로잉_탭/plan-task.md | 469 +++++++++++++++ docs/20260630_메인_홈_팔로잉_탭/prd.md | 572 +++++++++++++++++++ 2 files changed, 1041 insertions(+) create mode 100644 docs/20260630_메인_홈_팔로잉_탭/plan-task.md create mode 100644 docs/20260630_메인_홈_팔로잉_탭/prd.md diff --git a/docs/20260630_메인_홈_팔로잉_탭/plan-task.md b/docs/20260630_메인_홈_팔로잉_탭/plan-task.md new file mode 100644 index 00000000..94d7dc6d --- /dev/null +++ b/docs/20260630_메인_홈_팔로잉_탭/plan-task.md @@ -0,0 +1,469 @@ +# 메인 홈 팔로잉 탭 구현 계획 + +## 기준 문서 + +- PRD: `docs/20260630_메인_홈_팔로잉_탭/prd.md` +- 코드 스타일: `docs/agent-guides/code-style.md` +- 빌드/검증: `docs/agent-guides/build-test-verification.md` +- Figma 전체 화면: `24:5682` +- Figma 최근 소식 랭킹 카드: `24:5717` +- Figma 최근 소식 오디오 카드: `1229:27212` +- Figma 최근 소식 화보 카드: `1229:27213` + +## 구현 원칙 + +- `MainHomeView`는 홈 상단 title bar와 `추천`/`랭킹`/`팔로잉` Text tab shell만 담당한다. +- 팔로잉 탭의 API, 모델, ViewModel, 세부 UI는 `SodaLive/Sources/V2/Main/Home/Following/**` 아래에 둔다. +- 여러 화면에서 재사용할 근거가 생기기 전까지 팔로잉 전용 UI는 `SodaLive/Sources/V2/Main/Home/Following/Components/**`에 둔다. +- 기존 추천/랭킹 탭의 `TargetType`, Repository, ViewModel, `ApiResponse` 디코딩 패턴을 따른다. +- 홈 상단 `팔로잉` 탭 터치 시 로그인 guard를 먼저 적용한다. 비로그인 상태에서는 탭 전환과 API 호출을 하지 않고 로그인 화면으로 이동한다. +- 팔로잉 탭 전체 empty 문구는 `팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.`를 사용한다. +- API 실패 문구는 `팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.`를 사용한다. +- 섹션별 배열이 비어 있으면 해당 섹션은 숨긴다. +- 모든 뷰의 루트 container와 섹션 container에는 불필요한 고정 숫자 width/height를 사용하지 않는다. +- 프로필 이미지, 썸네일, 아이콘, tag, 최소 터치 영역처럼 UI 의미상 크기가 정해진 요소에만 고정 크기를 사용한다. +- 외부 라이브러리는 추가하지 않는다. +- Figma localhost asset URL은 앱 코드에 사용하지 않는다. + +## 구현 제약 + +- `FollowingCommunityPostNewsResponse`에는 `creatorId`가 없다. +- 현재 기존 커뮤니티 게시글 직접 진입 구조는 `AppState.setPendingCommunityCommentDeepLink(creatorId:postId:)`와 `.creatorCommunityAll(creatorId:)` 조합이므로 `postId`만으로는 기존 화면에 정확히 진입할 수 없다. +- `COMMUNITY_POST` tap 액션은 구현 전 서버가 `creatorId`를 추가하거나, `postId` 단독 상세 진입 route/API가 있는지 확인해야 한다. 이 확인 없이 임의로 수정 화면(`creatorCommunityModify`)이나 잘못된 creator 화면으로 이동하지 않는다. + +## 주요 대상 파일 + +### 생성 + +- `SodaLive/Sources/V2/Main/Home/Following/Models/HomeFollowingTabResponse.swift` +- `SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingApi.swift` +- `SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingRepository.swift` +- `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingViewModel.swift` +- `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingCreatorSection.swift` +- `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingLiveSection.swift` +- `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingChatSection.swift` +- `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingScheduleSection.swift` +- `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift` +- `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingRankingNewsCard.swift` +- `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingContentNewsCard.swift` +- `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingEmptyStateView.swift` + +### 수정 + +- `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift` +- `SodaLive/Sources/V2/Main/Home/MainHomeView.swift` +- `SodaLive/Sources/V2/Main/MainView.swift` +- `SodaLive/Sources/I18n/I18n.swift` +- 필요 시 `SodaLive.xcodeproj/project.pbxproj` + +### 확인 + +- `SodaLive/Sources/V2/Component/TextTabBar.swift` +- `SodaLive/Sources/V2/Component/SectionTitle.swift` +- `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` +- `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift` +- `SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingViewModel.swift` +- `SodaLive/Sources/App/AppStep.swift` +- `SodaLive/Sources/ContentView.swift` +- `SodaLive/Sources/Follow/FollowCreatorView.swift` +- `SodaLive/Sources/Chat/Talk/TalkView.swift` +- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift` + +## TASK 체크리스트 + +### Phase 1: 기준점 점검과 탭 guard 설계 + +- [ ] **Task 1.1: PRD와 기존 팔로잉 placeholder 확인** + - 대상 파일: + - 확인: `docs/20260630_메인_홈_팔로잉_탭/prd.md` + - 확인: `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift` + - 확인: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift` + - 작업 내용: + - PRD 성공 기준과 제외 범위를 다시 읽고 구현 범위를 확정한다. + - `MainHomeFollowingView`가 placeholder인지 확인한다. + - `MainHomeView`가 `TextTabBar` binding으로 탭을 전환하는 구조인지 확인한다. + - 검증 기준: + - 실행 명령: `sed -n '1,120p' SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift && sed -n '1,120p' SodaLive/Sources/V2/Main/Home/MainHomeView.swift` + - 기대 결과: 팔로잉 탭은 placeholder이고, `MainHomeView`는 탭 shell 역할만 담당한다. + +- [ ] **Task 1.2: 팔로잉 탭 선택 로그인 guard 연결** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift` + - 수정: `SodaLive/Sources/V2/Main/MainView.swift` + - 확인: `SodaLive/Sources/V2/Component/TextTabBar.swift` + - 작업 내용: + - `TextTabBar` 자체는 수정하지 않는다. + - `MainHomeView`에 `onSelectFollowingTab: () -> Bool` callback을 추가한다. + - `TextTabBar`의 `selectedItem`에는 custom `Binding`을 전달한다. + - binding setter에서 `.following` 선택 시 `onSelectFollowingTab()`이 `true`일 때만 `selectedTab = .following`을 실행한다. + - `MainView`에서 `onSelectFollowingTab`을 구현한다. + - 토큰이 비어 있으면 `AppState.shared.setAppStep(step: .login)`을 호출하고 `false`를 반환한다. + - 토큰이 있으면 `true`를 반환한다. + - 검증 기준: + - 실행 명령: `rg "onSelectFollowingTab|Binding\\(|selectedTab = \\.following|setAppStep\\(step: \\.login\\)|TextTabBar" SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift` + - 기대 결과: 팔로잉 탭 선택 guard가 `MainHomeView`와 `MainView`에 존재하고, `TextTabBar.swift`는 변경되지 않는다. + +### Phase 2: API, Repository, Response model, ViewModel + +- [ ] **Task 2.1: 팔로잉 응답 모델 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Home/Following/Models/HomeFollowingTabResponse.swift` + - 작업 내용: + - PRD의 Swift 모델 기준대로 `HomeFollowingTabResponse`, `FollowingCreatorResponse`, `FollowingLiveResponse`, `ChatRoomListItemResponse`, `FollowingScheduleResponse`, `FollowingNewsResponse`를 만든다. + - nested 최근 소식 모델 `FollowingCreatorRankingNewsResponse`, `FollowingContentNewsResponse`, `FollowingContentRankingNewsResponse`, `FollowingCommunityPostNewsResponse`를 만든다. + - `CreatorActivityType`, `FollowingNewsType`은 unknown case를 포함한 `Decodable, Hashable` enum으로 만든다. + - id가 있는 모델은 `Identifiable`을 채택한다. + - 검증 기준: + - 실행 명령: `rg "struct HomeFollowingTabResponse|struct ChatRoomListItemResponse|enum CreatorActivityType|struct FollowingNewsResponse|struct FollowingCreatorRankingNewsResponse|struct FollowingContentNewsResponse|struct FollowingContentRankingNewsResponse|struct FollowingCommunityPostNewsResponse|enum FollowingNewsType|unknown\\(" SodaLive/Sources/V2/Main/Home/Following/Models/HomeFollowingTabResponse.swift` + - 기대 결과: PRD의 응답 모델과 unknown enum 처리가 확인된다. + +- [ ] **Task 2.2: 팔로잉 API endpoint 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingApi.swift` + - 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/Repository/MainHomeRecommendationApi.swift` + - 작업 내용: + - `MainHomeFollowingApi`를 `TargetType`으로 만든다. + - case는 `getFollowing` 하나만 둔다. + - path는 `/api/v2/home/following`, method는 `.get`, task는 `.requestPlain`로 둔다. + - headers는 기존 V2 홈 API와 동일하게 `Authorization: Bearer {token}` 패턴을 사용한다. + - 검증 기준: + - 실행 명령: `rg "enum MainHomeFollowingApi|getFollowing|/api/v2/home/following|Authorization|requestPlain|TargetType" SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingApi.swift` + - 기대 결과: 팔로잉 전용 API endpoint와 인증 헤더가 확인된다. + +- [ ] **Task 2.3: 팔로잉 Repository 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingRepository.swift` + - 확인: `SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingRepository.swift` + - 작업 내용: + - `MainHomeFollowingRepository`를 만들고 `MoyaProvider`를 사용한다. + - `getFollowing() -> AnyPublisher` 메서드를 제공한다. + - 검증 기준: + - 실행 명령: `rg "final class MainHomeFollowingRepository|MoyaProvider|getFollowing\\(\\)|AnyPublisher" SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingRepository.swift` + - 기대 결과: 기존 V2 홈 Repository와 같은 패턴이 확인된다. + +- [ ] **Task 2.4: 팔로잉 ViewModel 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingViewModel.swift` + - 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift` + - 확인: `SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingViewModel.swift` + - 작업 내용: + - `MainHomeFollowingViewModel`을 `ObservableObject`로 만든다. + - `@Published var isLoading`, `@Published var response`, `@Published var isLoginRequired`, `@Published var message`, `@Published var hasLoaded`를 둔다. + - `fetchFollowing()`에서 `ApiResponse`를 디코딩한다. + - 성공 시 `response`와 `isLoginRequired`를 갱신한다. + - `isLoginRequired == true`이면 `response`는 nil로 두고 로그인 필요 상태를 표시할 수 있게 한다. + - 모든 섹션 배열이 비어 있으면 `message = I18n.HomeFollowing.emptyStateMessage`를 설정한다. + - API 실패/디코딩 실패 시 `message = I18n.HomeFollowing.loadFailedMessage`를 설정한다. + - 실패 시 toast 대신 안내 상태를 표시한다. + - 검증 기준: + - 실행 명령: `rg "final class MainHomeFollowingViewModel|fetchFollowing|ApiResponse|isLoginRequired|emptyStateMessage|loadFailedMessage|hasLoaded|ERROR_LOG" SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingViewModel.swift` + - 기대 결과: API 디코딩, 로그인 필요 상태, empty/API 실패 문구 처리가 확인된다. + +- [ ] **Task 2.5: 팔로잉 I18n 문구 추가** + - 대상 파일: + - 수정: `SodaLive/Sources/I18n/I18n.swift` + - 작업 내용: + - `I18n.HomeFollowing` enum을 추가한다. + - `emptyStateMessage` ko는 `팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.`로 둔다. + - `loadFailedMessage` ko는 `팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.`로 둔다. + - `loginRequiredMessage`, `loginButtonTitle`, `allButtonTitle`, `onAirTitle`, `recentChatTitle`, `monthlyScheduleTitle`, `recentNewsTitle`, `audioTag`, `photoTag`를 추가한다. + - en/ja는 기능 검증을 막지 않는 자연스러운 대응 문구로 추가한다. + - 검증 기준: + - 실행 명령: `rg "enum HomeFollowing|emptyStateMessage|loadFailedMessage|팔로잉 소식이 아직 없어요|팔로잉 정보를 불러오지 못했습니다|allButtonTitle|recentNewsTitle|audioTag|photoTag" SodaLive/Sources/I18n/I18n.swift` + - 기대 결과: 팔로잉 탭 사용자 노출 문구가 I18n에 존재한다. + +### Phase 3: 팔로잉 크리에이터 섹션과 전체 버튼 + +- [ ] **Task 3.1: 팔로잉 크리에이터 섹션 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingCreatorSection.swift` + - 확인: `SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift` + - 확인: `SodaLive/Sources/Follow/FollowCreatorView.swift` + - 작업 내용: + - `followingCreators`를 가로 스크롤 목록으로 표시한다. + - 각 creator item은 프로필 이미지와 닉네임을 표시한다. + - creator item 탭 시 `onTapCreator(creatorId)`를 호출한다. + - 목록 마지막에는 항상 `전체` 버튼을 표시한다. + - `전체` 버튼은 `followingCreators`가 비어 있어도 표시한다. + - `전체` 버튼 text는 `I18n.HomeFollowing.allButtonTitle`, 색상은 `Color.soda400`을 사용한다. + - `전체` 버튼 horizontal padding은 16으로 둔다. + - `전체` 버튼 높이는 creator item 높이와 동일하게 맞춘다. + - `전체` 버튼 탭 시 `onTapAll()`을 호출한다. + - 검증 기준: + - 실행 명령: `rg "struct MainHomeFollowingCreatorSection|ScrollView\\(\\.horizontal|followingCreators|onTapCreator|onTapAll|allButtonTitle|Color.soda400|padding\\(\\.horizontal, 16\\)" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingCreatorSection.swift` + - 기대 결과: 가로 creator 목록과 마지막 `전체` 버튼 요구사항이 확인된다. + +- [ ] **Task 3.2: 전체 팔로잉 목록 진입 연결** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift` + - 수정: `SodaLive/Sources/V2/Main/MainView.swift` + - 확인: `SodaLive/Sources/App/AppStep.swift` + - 확인: `SodaLive/Sources/ContentView.swift` + - 작업 내용: + - `MainHomeView`에 `onTapFollowingAll: () -> Void` callback을 추가한다. + - `MainHomeFollowingView`까지 callback을 전달한다. + - `MainView`에서 `onTapFollowingAll`을 `AppState.shared.setAppStep(step: .followingList)`로 구현한다. + - 검증 기준: + - 실행 명령: `rg "onTapFollowingAll|followingList|FollowCreatorView" SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift` + - 기대 결과: `전체` 버튼이 기존 `FollowCreatorView` 라우팅으로 연결된다. + +### Phase 4: On Air 섹션 + +- [ ] **Task 4.1: On Air 섹션 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingLiveSection.swift` + - 작업 내용: + - `onAirLives`가 비어 있으면 parent에서 섹션을 렌더링하지 않는다. + - 섹션 타이틀은 `I18n.HomeFollowing.onAirTitle`을 사용한다. + - 각 item은 프로필 이미지, 닉네임, 라이브 제목, `startedAtUtc` 기준 경과 시간을 표시한다. + - 시간 표시는 기존 `DateParser.relativeTimeText(fromUTC:fallback:now:)` 패턴을 우선 사용한다. + - item 탭 시 `onTapLive(liveId)`를 호출한다. + - 카드 루트에는 고정 width/height를 강제하지 않는다. + - 검증 기준: + - 실행 명령: `rg "struct MainHomeFollowingLiveSection|onAirTitle|FollowingLiveResponse|startedAtUtc|DateParser|onTapLive|DownsampledKFImage|ScrollView\\(\\.horizontal" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingLiveSection.swift` + - 기대 결과: On Air 섹션의 데이터 표시와 live tap callback이 확인된다. + +### Phase 5: 최근 대화 섹션 + +- [ ] **Task 5.1: 최근 대화 섹션 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingChatSection.swift` + - 확인: `SodaLive/Sources/Chat/Talk/TalkView.swift` + - 확인: `SodaLive/Sources/Chat/Talk/TalkItemView.swift` + - 작업 내용: + - `recentChats`가 비어 있으면 parent에서 섹션을 렌더링하지 않는다. + - 섹션 타이틀은 `I18n.HomeFollowing.recentChatTitle`을 사용한다. + - 각 item은 `targetImageUrl`, `targetName`, `lastMessage`, `lastMessageAt`을 표시한다. + - `chatType`은 현재 PRD에서 노출 요구가 없으므로 표시하지 않는다. + - `lastMessageAt`은 UTC 기준 문자열로 받아 상대 시간 또는 기존 Talk item과 맞는 시간 label로 표시한다. + - item 탭 시 `onTapChatRoom(roomId)`를 호출한다. + - 검증 기준: + - 실행 명령: `rg "struct MainHomeFollowingChatSection|recentChatTitle|ChatRoomListItemResponse|targetImageUrl|targetName|lastMessage|lastMessageAt|onTapChatRoom" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingChatSection.swift` + - 기대 결과: 최근 대화 item 필드와 채팅방 tap callback이 확인된다. + +- [ ] **Task 5.2: 채팅방 진입 연결** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift` + - 수정: `SodaLive/Sources/V2/Main/MainView.swift` + - 확인: `SodaLive/Sources/App/AppStep.swift` + - 확인: `SodaLive/Sources/ContentView.swift` + - 작업 내용: + - `MainHomeView`에 `onTapChatRoom: (Int) -> Void` callback을 추가한다. + - `MainHomeFollowingView`까지 callback을 전달한다. + - `MainView`에서 `onTapChatRoom`을 `AppState.shared.setAppStep(step: .chatRoom(id: roomId))`로 구현한다. + - 검증 기준: + - 실행 명령: `rg "onTapChatRoom|chatRoom\\(id:|ChatRoomView" SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift` + - 기대 결과: 최근 대화 item tap이 기존 채팅방 화면으로 연결된다. + +### Phase 6: 이달의 스케줄 섹션 + +- [ ] **Task 6.1: 이달의 스케줄 섹션 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingScheduleSection.swift` + - 작업 내용: + - `monthlySchedules`가 비어 있으면 parent에서 섹션을 렌더링하지 않는다. + - 섹션 타이틀은 `I18n.HomeFollowing.monthlyScheduleTitle`을 사용한다. + - 각 item은 날짜 영역, 크리에이터 프로필/닉네임, 제목, 활동 타입 tag, 시간 또는 `On Air` 상태를 표시한다. + - `scheduledAtUtc`가 오늘이면 날짜 영역에 `오늘`을 표시한다. + - `isOnAir == true`이면 우측 상태를 `On Air`로 표시하고 `Color.soda400`을 사용한다. + - `isOnAir == true`이면 좌측 accent line을 표시한다. + - `CreatorActivityType.live`, `.liveReplay`, `.audio`, `.community`를 tag 문구로 매핑한다. + - item 탭 시 `onTapSchedule(type, targetId)`를 호출한다. + - 검증 기준: + - 실행 명령: `rg "struct MainHomeFollowingScheduleSection|monthlyScheduleTitle|FollowingScheduleResponse|scheduledAtUtc|isOnAir|Color.soda400|CreatorActivityType|onTapSchedule|오늘" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingScheduleSection.swift` + - 기대 결과: 스케줄 카드 표시, On Air 강조, schedule tap callback이 확인된다. + +- [ ] **Task 6.2: 스케줄 상세 진입 연결** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift` + - 수정: `SodaLive/Sources/V2/Main/MainView.swift` + - 작업 내용: + - `MainHomeView`에 `onTapFollowingSchedule: (CreatorActivityType, Int) -> Void` callback을 추가한다. + - `.live`, `.liveReplay`은 기존 live 입장 callback으로 연결한다. + - `.audio`는 `AppState.shared.setAppStep(step: .contentDetail(contentId: targetId))` 흐름으로 연결한다. + - `.community`는 기존 커뮤니티 진입 정책이 `creatorId` 기반인지 `postId` 기반인지 구현 전 확인한다. `targetId`가 creator id로 내려오는 경우에만 `.creatorCommunityAll(creatorId: targetId)`로 연결한다. + - 검증 기준: + - 실행 명령: `rg "onTapFollowingSchedule|CreatorActivityType|contentDetail|creatorCommunityAll|enterLiveRoom" SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift` + - 기대 결과: schedule type별 routing 분기가 확인된다. + +### Phase 7: 최근 소식 랭킹 카드 + +- [ ] **Task 7.1: 최근 소식 랭킹 카드 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingRankingNewsCard.swift` + - 작업 내용: + - Figma `24:5717` 기준 카드 형태로 만든다. + - `CREATOR_RANKING`은 `FollowingCreatorRankingNewsResponse`를 사용한다. + - `CONTENT_RANKING`은 `FollowingContentRankingNewsResponse`를 사용한다. + - `rank` 값을 `N위`로 표시하고 `Color.soda400`로 강조한다. + - 타입과 payload가 맞지 않으면 parent에서 렌더링하지 않는다. + - card tap 시 creator ranking은 `onTapCreator(creatorId)`, content ranking은 `onTapContent(contentId)`를 호출한다. + - 검증 기준: + - 실행 명령: `rg "struct MainHomeFollowingRankingNewsCard|FollowingCreatorRankingNewsResponse|FollowingContentRankingNewsResponse|rank|Color.soda400|onTapCreator|onTapContent|24:5717" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingRankingNewsCard.swift` + - 기대 결과: 랭킹 최근 소식 카드와 타입별 tap callback이 확인된다. + +### Phase 8: 최근 소식 커뮤니티 카드 + +- [ ] **Task 8.1: 최근 소식 커뮤니티 카드 조립** + - 대상 파일: + - 생성 또는 수정: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift` + - 확인: `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` + - 작업 내용: + - `COMMUNITY_POST`는 `communityPost` payload가 있을 때만 렌더링한다. + - 표시 값은 `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, `createdAt`, `likeCount`, `commentCount`를 사용한다. + - 기존 `CommunityPostCard`를 재사용할 경우 `audioUrl = nil`, `price = 0`, `existOrdered = true`, `isLike = false`로 매핑한다. + - `createdAt`은 기존 `CommunityPostCard`의 `createdAt` 표시 형식에 맞춰 전달한다. + - 검증 기준: + - 실행 명령: `rg "COMMUNITY_POST|communityPost|CommunityPostCard|postId|creatorProfileImage|likeCount|commentCount|audioUrl: nil|price: 0|existOrdered: true" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift` + - 기대 결과: community post payload가 기존 community card 표시값으로 매핑된다. + +- [ ] **Task 8.2: 커뮤니티 게시글 tap 라우팅 제약 처리** + - 대상 파일: + - 확인: `SodaLive/Sources/App/AppState.swift` + - 확인: `SodaLive/Sources/App/AppStep.swift` + - 확인: `SodaLive/Sources/ContentView.swift` + - 확인: `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift` + - 작업 내용: + - `postId` 단독으로 게시글 상세/댓글 화면에 진입 가능한 기존 route가 있는지 확인한다. + - 현재 확인된 기존 route가 `creatorId`를 요구하는 경우, 잘못된 화면 이동을 구현하지 않는다. + - 서버 응답에 `creatorId`가 추가되거나 `postId` 단독 route가 생기기 전까지 `COMMUNITY_POST` tap은 표시 카드 내부의 좋아요/댓글 표시만 제공하고 상세 진입은 연결하지 않는다. + - 이 제약을 구현 결과 보고와 검증 기록에 남긴다. + - 검증 기준: + - 실행 명령: `rg "setPendingCommunityCommentDeepLink|consumePendingCommunityCommentPostId|creatorCommunityAll|case chatRoom|case creatorCommunity" SodaLive/Sources/App/AppState.swift SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift` + - 기대 결과: 기존 커뮤니티 직접 진입 경로가 `creatorId`를 요구하는지 확인된다. + +### Phase 9: 최근 소식 오디오/화보 콘텐츠 카드 + +- [ ] **Task 9.1: 최근 소식 콘텐츠 카드 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingContentNewsCard.swift` + - 작업 내용: + - Figma `1229:27212` 오디오 카드와 `1229:27213` 화보 카드 형태를 하나의 카드 컴포넌트에서 variant로 처리한다. + - `AUDIO_CONTENT`는 정사각형 썸네일 비율과 `I18n.HomeFollowing.audioTag`를 사용한다. + - `PHOTO_CONTENT`는 세로형 썸네일 비율과 `I18n.HomeFollowing.photoTag`를 사용한다. + - `contentImageUrl`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `visibleFromAtUtc` 표시값을 받는다. + - card tap 시 `onTapContent(contentId)`를 호출한다. + - 루트 container에는 고정 width를 사용하지 않는다. + - 검증 기준: + - 실행 명령: `rg "struct MainHomeFollowingContentNewsCard|audioTag|photoTag|contentImageUrl|creatorProfileImageUrl|creatorNickname|visibleFromAtUtc|onTapContent|aspectRatio" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingContentNewsCard.swift` + - 기대 결과: 오디오/화보 variant와 콘텐츠 상세 callback이 확인된다. + +### Phase 10: 화면 조립, empty/error 상태, 상세 진입 callback 연결 + +- [ ] **Task 10.1: 팔로잉 탭 화면 조립** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift` + - 작업 내용: + - placeholder를 제거한다. + - `@StateObject private var viewModel = MainHomeFollowingViewModel()`를 사용한다. + - 최초 `onAppear`에서 `hasLoaded == false`일 때만 `fetchFollowing()`을 호출한다. + - loading, loginRequired, message, content 상태를 분기한다. + - content 상태에서는 PRD 순서대로 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 최근 소식을 조립한다. + - 각 섹션 배열이 비어 있으면 해당 섹션을 렌더링하지 않는다. + - 모든 섹션 배열이 비어 있으면 `MainHomeFollowingEmptyStateView(message: I18n.HomeFollowing.emptyStateMessage)`를 표시한다. + - API 실패 시 `MainHomeFollowingEmptyStateView(message: I18n.HomeFollowing.loadFailedMessage)`를 표시한다. + - 검증 기준: + - 실행 명령: `rg "MainHomeFollowingViewModel|fetchFollowing|MainHomeFollowingCreatorSection|MainHomeFollowingLiveSection|MainHomeFollowingChatSection|MainHomeFollowingScheduleSection|MainHomeFollowingNewsSection|MainHomeFollowingEmptyStateView|emptyStateMessage|loadFailedMessage|MainPlaceholderTabView" SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift` + - 기대 결과: `MainPlaceholderTabView`는 검색되지 않고, 팔로잉 화면 상태 분기와 섹션 조립이 확인된다. + +- [ ] **Task 10.2: empty/login required view 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingEmptyStateView.swift` + - 작업 내용: + - message를 받아 검정 배경 위에 표시한다. + - 로그인 필요 상태에서는 로그인 버튼을 표시할 수 있도록 optional action을 받는다. + - 전체 루트에는 고정 width/height를 사용하지 않는다. + - 검증 기준: + - 실행 명령: `rg "struct MainHomeFollowingEmptyStateView|message|loginButtonTitle|Button|Color.black|frame\\(maxWidth: \\.infinity" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingEmptyStateView.swift` + - 기대 결과: empty/API 실패/로그인 필요 상태 공용 안내 view가 확인된다. + +- [ ] **Task 10.3: 최근 소식 섹션 조립** + - 대상 파일: + - 생성 또는 수정: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift` + - 작업 내용: + - `recentNews`를 순서대로 렌더링한다. + - `visibleFromAtUtc`를 모든 item의 표시 시간 기준으로 사용한다. + - `.creatorRanking`은 `creatorRanking` payload가 있을 때만 `MainHomeFollowingRankingNewsCard`를 표시한다. + - `.contentRanking`은 `contentRanking` payload가 있을 때만 `MainHomeFollowingRankingNewsCard`를 표시한다. + - `.communityPost`는 `communityPost` payload가 있을 때만 community card를 표시한다. + - `.audioContent`는 `audioContent` payload가 있을 때만 `MainHomeFollowingContentNewsCard`를 표시한다. + - `.photoContent`는 `photoContent` payload가 있을 때만 `MainHomeFollowingContentNewsCard`를 표시한다. + - `.unknown`은 렌더링하지 않는다. + - 검증 기준: + - 실행 명령: `rg "struct MainHomeFollowingNewsSection|recentNewsTitle|visibleFromAtUtc|creatorRanking|contentRanking|communityPost|audioContent|photoContent|unknown|MainHomeFollowingRankingNewsCard|MainHomeFollowingContentNewsCard|CommunityPostCard" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift` + - 기대 결과: 최근 소식 타입별 payload 분기와 unknown 숨김 정책이 확인된다. + +- [ ] **Task 10.4: 상세 진입 callback 연결** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift` + - 수정: `SodaLive/Sources/V2/Main/MainView.swift` + - 작업 내용: + - `MainHomeView`에 팔로잉 탭용 callback을 추가한다. + - live는 기존 `handleRecommendationLiveTap` 또는 같은 login/external navigation guard를 재사용한다. + - creator는 기존 `handleRecommendationCreatorTap`을 재사용한다. + - content는 기존 `handleRecommendationContentTap`을 재사용한다. + - chat room은 `.chatRoom(id:)`로 이동한다. + - following all은 `.followingList`로 이동한다. + - community post는 Task 8.2 제약에 따라 postId 단독 route가 없으면 상세 진입을 연결하지 않는다. + - 검증 기준: + - 실행 명령: `rg "onTapFollowingAll|onTapChatRoom|onTapFollowingSchedule|handleRecommendationLiveTap|handleRecommendationCreatorTap|handleRecommendationContentTap|followingList|chatRoom\\(id:" SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift` + - 기대 결과: 팔로잉 탭의 상세 진입 callback이 기존 MainView guard/route에 연결된다. + +### Phase 11: 프로젝트 등록, 빌드, UI 검증 + +- [ ] **Task 11.1: Xcode 프로젝트 포함 여부 확인** + - 대상 파일: + - 확인: `SodaLive.xcodeproj/project.pbxproj` + - 확인: `SodaLive/Sources/V2/Main/Home/Following/**` + - 작업 내용: + - 신규 Swift 파일들이 Xcode target에 포함됐는지 확인한다. + - 파일이 target에 자동 포함되지 않는 프로젝트 구조이면 `project.pbxproj`에 필요한 file reference/build file을 추가한다. + - 검증 기준: + - 실행 명령: `rg "HomeFollowingTabResponse|MainHomeFollowingApi|MainHomeFollowingRepository|MainHomeFollowingViewModel|MainHomeFollowingCreatorSection|MainHomeFollowingNewsSection|MainHomeFollowingEmptyStateView" SodaLive.xcodeproj/project.pbxproj SodaLive/Sources/V2/Main/Home/Following` + - 기대 결과: 신규 파일 참조와 소스 파일이 확인된다. + +- [ ] **Task 11.2: 정적 검색 검증** + - 대상 파일: + - 확인: `SodaLive/Sources/V2/Main/Home/Following/**` + - 확인: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift` + - 확인: `SodaLive/Sources/V2/Main/MainView.swift` + - 확인: `SodaLive/Sources/I18n/I18n.swift` + - 작업 내용: + - PRD 핵심 키워드가 코드에 반영됐는지 확인한다. + - `MainPlaceholderTabView(title: "팔로잉")`이 남아 있지 않은지 확인한다. + - Figma localhost asset URL이 들어가지 않았는지 확인한다. + - 검증 기준: + - 실행 명령: `rg "/api/v2/home/following|HomeFollowingTabResponse|ChatRoomListItemResponse|CreatorActivityType|FollowingNewsType|visibleFromAtUtc|팔로잉 소식이 아직 없어요|팔로잉 정보를 불러오지 못했습니다|Color.soda400|followingList|chatRoom\\(id:" SodaLive/Sources/V2/Main/Home/Following SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift SodaLive/Sources/I18n/I18n.swift` + - 기대 결과: API, 모델, 문구, 라우팅, 색상 요구사항이 확인된다. + +- [ ] **Task 11.3: 빌드 검증** + - 대상 파일: + - 확인: `SodaLive.xcworkspace` + - 작업 내용: + - 앱 빌드를 실행해 신규 Swift 파일과 라우팅 변경의 컴파일 오류를 확인한다. + - 검증 기준: + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - 기대 결과: 빌드가 성공한다. + +- [ ] **Task 11.4: 수동 UI 검증** + - 대상 파일: + - 확인: `SodaLive/Sources/V2/Main/Home/Following/**` + - 확인: Figma `24:5682` + - 작업 내용: + - 비로그인 상태에서 홈 `팔로잉` 탭을 탭하면 로그인 화면으로 이동하는지 확인한다. + - 로그인 상태에서 홈 `팔로잉` 탭 진입 시 API 호출과 섹션 표시가 동작하는지 확인한다. + - 팔로잉 크리에이터 섹션 마지막에 `전체` 버튼이 항상 표시되는지 확인한다. + - `전체` 버튼 text color가 `Color.soda400`로 보이는지 확인한다. + - `CREATOR_RANKING`, `CONTENT_RANKING`, `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`가 타입별 UI로 표시되는지 확인한다. + - 루트/섹션 container가 고정 width/height 때문에 작은 화면에서 깨지지 않는지 확인한다. + - 검증 기준: + - 실행 명령: 수동 확인 + - 기대 결과: PRD 성공 기준과 Figma 섹션 순서가 충족된다. + +## 검증 기록 + +- 2026-06-30: `docs/agent-guides/documentation-policy.md`, `build-test-verification.md`, `code-style.md`를 확인해 계획 문서 경로, Phase/checklist 형식, 검증 명령 기준을 반영했다. +- 2026-06-30: `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift`가 placeholder 상태임을 확인했다. +- 2026-06-30: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`의 탭 전환이 `TextTabBar` binding 기반임을 확인해 custom binding setter 방식으로 로그인 guard 계획을 세웠다. +- 2026-06-30: `SodaLive/Sources/ContentView.swift`에서 `.followingList -> FollowCreatorView()`, `.chatRoom(id:) -> ChatRoomView(roomId:)` 라우팅을 확인했다. +- 2026-06-30: `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift`와 `AppState`의 pending community post 구조가 `creatorId`를 요구함을 확인해 `COMMUNITY_POST` tap 라우팅 제약을 계획에 명시했다. diff --git a/docs/20260630_메인_홈_팔로잉_탭/prd.md b/docs/20260630_메인_홈_팔로잉_탭/prd.md new file mode 100644 index 00000000..e2291280 --- /dev/null +++ b/docs/20260630_메인_홈_팔로잉_탭/prd.md @@ -0,0 +1,572 @@ +# PRD: 메인 홈 팔로잉 탭 + +## 1. Overview +메인 홈 화면의 `팔로잉` 탭에서 신규 API `GET /api/v2/home/following` 응답을 사용해 팔로잉 크리에이터, On Air 라이브, 최근 대화, 이달의 스케줄, 최근 소식을 표시한다. + +기존 `MainHomeView`는 홈 상단 공통 shell과 `추천`/`랭킹`/`팔로잉` 탭 전환만 담당한다. 팔로잉 탭의 API 호출, 상태 관리, 화면 조립은 `SodaLive/Sources/V2/Main/Home/Following/**` 하위에서 처리한다. + +Figma 전체 화면 기준 섹션 순서는 아래를 따른다. + +1. 팔로잉 크리에이터 +2. On Air +3. 최근 대화 +4. 이달의 스케줄 +5. 최근 소식 + +## 2. Problem +- 현재 `MainHomeFollowingView`는 placeholder 상태라 홈의 `팔로잉` 탭에서 실제 팔로잉 기반 데이터를 제공하지 못한다. +- 팔로잉 탭은 여러 도메인 데이터를 한 endpoint에서 받지만, 각 섹션의 UI와 진입 액션이 다르므로 화면 파일 하나에 직접 구현하면 유지보수와 검증이 어려워진다. +- 최근 소식은 `FollowingNewsType`별로 서로 다른 카드 형태가 필요하다. +- Figma 화면에는 고정 숫자 width/height가 포함되어 있지만, 앱 구현에서는 화면 width와 콘텐츠에 대응해야 하므로 필요한 경우를 제외하고 고정 크기를 피해야 한다. +- 로그인하지 않은 사용자는 팔로잉 기반 데이터를 볼 수 없을 수 있으므로 `isLoginRequired` 상태를 명확히 처리해야 한다. + +## 3. Goals +- `팔로잉` 탭 진입 시 `GET /api/v2/home/following`을 호출하고 응답 데이터로 화면을 구성한다. +- 팔로잉 탭 전용 API, Repository, ViewModel, Response model을 `SodaLive/Sources/V2/Main/Home/Following/**` 아래에 둔다. +- 기존 V2 공용 컴포넌트와 추천/랭킹 탭 패턴을 우선 재사용한다. +- 홈 상단 `팔로잉` 탭 터치 시 로그인 guard를 먼저 적용해 로그인된 사용자만 탭으로 이동한다. +- `isLoginRequired == true`이면 팔로잉 기반 콘텐츠 대신 로그인 필요 상태를 표시하고, 사용자가 로그인 화면으로 이동할 수 있어야 한다. +- 팔로잉 크리에이터 섹션의 가로 목록 마지막 item에는 항상 `전체` 버튼을 추가한다. +- 최근 소식은 `FollowingNewsType`별로 다른 View를 표시한다. +- 각 섹션은 이후 `plan-task.md` 작성 시 Figma Design 기준 섹션당 1개의 Phase로 나눌 수 있도록 요구사항을 분리한다. +- 뷰 전체는 반드시 필요한 경우를 제외하고 고정 숫자 width/height를 갖지 않는다. + +## 4. Non-Goals +- 추천 탭, 랭킹 탭, 하단 메인 탭 구조를 변경하지 않는다. +- 기존 구 홈 화면의 API나 UI를 변경하지 않는다. +- 이번 API 스펙에 없는 페이지네이션, 무한 스크롤, 섹션별 정렬/필터 기능을 추가하지 않는다. +- Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다. +- 서버 응답에 없는 팔로잉 상태 변경, 채팅방 생성, 스케줄 예약/취소 기능을 추가하지 않는다. +- 기존 `AudioContentCard`처럼 고정 width 중심으로 설계된 컴포넌트를 요구사항과 맞지 않게 억지 재사용하지 않는다. + +## 5. Target Users +- 팔로우한 크리에이터의 라이브, 스케줄, 소식을 홈에서 빠르게 확인하려는 사용자 +- 팔로우한 크리에이터의 최근 대화방으로 빠르게 복귀하려는 사용자 +- 팔로우한 크리에이터의 랭킹, 커뮤니티, 오디오, 화보 소식을 한 화면에서 탐색하려는 사용자 + +## 6. User Stories +- 사용자는 홈 `팔로잉` 탭에서 내가 팔로우한 크리에이터 목록을 보고 싶다. +- 사용자는 팔로잉 크리에이터 목록 끝의 `전체` 버튼으로 전체 팔로잉 목록을 보고 싶다. +- 사용자는 팔로우한 크리에이터가 라이브 중이면 `On Air` 섹션에서 바로 진입하고 싶다. +- 사용자는 최근 대화 섹션에서 최근 채팅방을 확인하고 이어서 대화하고 싶다. +- 사용자는 이달의 스케줄에서 예정된 라이브/콘텐츠와 On Air 여부를 확인하고 싶다. +- 사용자는 최근 소식에서 랭킹, 커뮤니티, 오디오 콘텐츠, 화보 콘텐츠 소식을 타입에 맞는 UI로 보고 싶다. +- 로그인하지 않은 사용자는 팔로잉 탭에서 로그인 필요 상태를 이해하고 로그인 화면으로 이동하고 싶다. + +## 7. Core Requirements + +### 7.1 팔로잉 홈 데이터 조회 + +#### API +- Method: `GET` +- Path: `/api/v2/home/following` +- 인증: `Authorization: Bearer {accessToken}` 사용. endpoint 자체는 optional 인증을 허용하지만, 클라이언트는 팔로잉 탭 터치 시 로그인 guard를 먼저 적용해 비로그인 상태에서는 API를 호출하지 않고 로그인 화면으로 이동한다. +- 응답 래퍼: 기존 관례대로 `ApiResponse` 디코딩을 우선한다. + +#### Response +```kotlin +data class HomeFollowingTabResponse( + val isLoginRequired: Boolean, + val followingCreators: List, + val onAirLives: List, + val recentChats: List, + val monthlySchedules: List, + val recentNews: List +) + +data class FollowingCreatorResponse( + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImageUrl: String +) + +data class FollowingLiveResponse( + val liveId: Long, + val creatorProfileImageUrl: String, + val creatorNickname: String, + val title: String, + val startedAtUtc: String +) + +data class ChatRoomListItemResponse( + val roomId: Long, + val chatType: String, + val targetName: String, + val targetImageUrl: String, + val lastMessage: String, + val lastMessageAt: String +) + +data class FollowingScheduleResponse( + val scheduleId: String, + val creatorId: Long, + val creatorProfileImageUrl: String, + val creatorNickname: String, + val title: String, + val type: CreatorActivityType, + val targetId: Long, + val scheduledAtUtc: String, + val isOnAir: Boolean +) + +enum class CreatorActivityType(val code: String) { + LIVE("LIVE"), + AUDIO("AUDIO"), + COMMUNITY("COMMUNITY"), + LIVE_REPLAY("LIVE_REPLAY") +} + +data class FollowingNewsResponse( + val newsId: String, + val type: FollowingNewsType, + val visibleFromAtUtc: String, + val creatorRanking: FollowingCreatorRankingNewsResponse?, + val audioContent: FollowingContentNewsResponse?, + val photoContent: FollowingContentNewsResponse?, + val contentRanking: FollowingContentRankingNewsResponse?, + val communityPost: FollowingCommunityPostNewsResponse? +) + +data class FollowingCreatorRankingNewsResponse( + val rank: Int, + val creatorId: Long, + val nickname: String, + val profileImageUrl: String +) + +data class FollowingContentNewsResponse( + val contentId: Long, + val contentImageUrl: String?, + val title: String, + val creatorProfileImageUrl: String, + val creatorNickname: String +) + +data class FollowingContentRankingNewsResponse( + val rank: Int, + val contentId: Long, + val contentImageUrl: String?, + val title: String +) + +data class FollowingCommunityPostNewsResponse( + val postId: Long, + val creatorProfileImage: String, + val creatorNickname: String, + val imageUrl: String?, + val content: String, + val createdAt: String, + val likeCount: Int, + val commentCount: Int +) + +enum class FollowingNewsType { + CREATOR_RANKING, + CONTENT_RANKING, + COMMUNITY_POST, + AUDIO_CONTENT, + PHOTO_CONTENT +} +``` + +#### Swift 모델 기준 +```swift +struct HomeFollowingTabResponse: Decodable { + let isLoginRequired: Bool + let followingCreators: [FollowingCreatorResponse] + let onAirLives: [FollowingLiveResponse] + let recentChats: [ChatRoomListItemResponse] + let monthlySchedules: [FollowingScheduleResponse] + let recentNews: [FollowingNewsResponse] +} + +struct FollowingCreatorResponse: Decodable, Identifiable { + let creatorId: Int + let creatorNickname: String + let creatorProfileImageUrl: String + + var id: Int { creatorId } +} + +struct FollowingLiveResponse: Decodable, Identifiable { + let liveId: Int + let creatorProfileImageUrl: String + let creatorNickname: String + let title: String + let startedAtUtc: String + + var id: Int { liveId } +} + +struct ChatRoomListItemResponse: Decodable, Identifiable { + let roomId: Int + let chatType: String + let targetName: String + let targetImageUrl: String + let lastMessage: String + let lastMessageAt: String + + var id: Int { roomId } +} + +struct FollowingScheduleResponse: Decodable, Identifiable { + let scheduleId: String + let creatorId: Int + let creatorProfileImageUrl: String + let creatorNickname: String + let title: String + let type: CreatorActivityType + let targetId: Int + let scheduledAtUtc: String + let isOnAir: Bool + + var id: String { scheduleId } +} + +enum CreatorActivityType: Decodable, Hashable { + case live + case audio + case community + case liveReplay + case unknown(String) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let code = try container.decode(String.self) + + switch code { + case "LIVE": + self = .live + case "AUDIO": + self = .audio + case "COMMUNITY": + self = .community + case "LIVE_REPLAY": + self = .liveReplay + default: + self = .unknown(code) + } + } +} + +struct FollowingNewsResponse: Decodable, Identifiable { + let newsId: String + let type: FollowingNewsType + let visibleFromAtUtc: String + let creatorRanking: FollowingCreatorRankingNewsResponse? + let audioContent: FollowingContentNewsResponse? + let photoContent: FollowingContentNewsResponse? + let contentRanking: FollowingContentRankingNewsResponse? + let communityPost: FollowingCommunityPostNewsResponse? + + var id: String { newsId } +} + +struct FollowingCreatorRankingNewsResponse: Decodable { + let rank: Int + let creatorId: Int + let nickname: String + let profileImageUrl: String +} + +struct FollowingContentNewsResponse: Decodable { + let contentId: Int + let contentImageUrl: String? + let title: String + let creatorProfileImageUrl: String + let creatorNickname: String +} + +struct FollowingContentRankingNewsResponse: Decodable { + let rank: Int + let contentId: Int + let contentImageUrl: String? + let title: String +} + +struct FollowingCommunityPostNewsResponse: Decodable { + let postId: Int + let creatorProfileImage: String + let creatorNickname: String + let imageUrl: String? + let content: String + let createdAt: String + let likeCount: Int + let commentCount: Int +} + +enum FollowingNewsType: Decodable, Hashable { + case creatorRanking + case contentRanking + case communityPost + case audioContent + case photoContent + case unknown(String) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let code = try container.decode(String.self) + + switch code { + case "CREATOR_RANKING": + self = .creatorRanking + case "CONTENT_RANKING": + self = .contentRanking + case "COMMUNITY_POST": + self = .communityPost + case "AUDIO_CONTENT": + self = .audioContent + case "PHOTO_CONTENT": + self = .photoContent + default: + self = .unknown(code) + } + } +} +``` + +`Long` 값은 현재 V2 홈 모델 관례에 맞춰 Swift `Int`로 선언한다. 운영 데이터에서 이미지 URL이 빈 문자열로 내려올 수 있으므로 이미지 로딩 실패 시 기존 기본 배경/placeholder 처리 패턴을 따른다. + +### 7.2 로그인 필요 상태 + +- 홈 상단 `팔로잉` 탭을 터치할 때 로그인 guard를 먼저 확인한다. +- 로그인되어 있으면 `팔로잉` 탭으로 전환한다. +- 로그인되어 있지 않으면 `팔로잉` 탭으로 전환하지 않고 기존 `AppState.shared.setAppStep(step: .login)` 흐름으로 로그인 화면에 이동한다. +- API 응답의 `isLoginRequired == true`이면 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 최근 소식 섹션을 표시하지 않는다. +- `isLoginRequired == true`는 토큰 만료, 서버 판단 등 탭 진입 전 guard를 통과한 뒤에도 로그인 필요 상태가 내려오는 방어 케이스로 취급한다. +- 로그인 필요 상태에는 로그인 안내 문구와 로그인 진입 액션을 제공한다. +- 로그인 버튼 탭 시 기존 `AppState.shared.setAppStep(step: .login)` 흐름을 사용한다. +- `isLoginRequired == false`이면 섹션별 배열이 비어 있는 경우 해당 섹션만 숨긴다. +- API 실패 시에는 기존 추천/랭킹 탭과 유사하게 사용자에게 과도한 오류 UI를 추가하지 않고 안내 상태를 표시한다. +- 전체 empty state 문구는 `팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.`로 표시한다. +- API 실패 문구는 `팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.`로 표시한다. + +### 7.3 팔로잉 크리에이터 섹션 + +Figma 참조: +- 전체 화면: `node-id=24-5682` + +요구사항: +- `followingCreators`를 가로 스크롤 목록으로 표시한다. +- 각 item은 프로필 이미지와 크리에이터 닉네임을 표시한다. +- 크리에이터 item 탭 시 `creatorId`로 기존 크리에이터 상세 화면에 진입한다. +- 목록 마지막 item에는 항상 `전체` 버튼을 추가한다. +- `전체` 버튼은 서버 데이터가 비어 있어도 표시한다. +- `전체` 버튼의 width는 버튼이 차지하는 콘텐츠 크기 기준으로 잡고, 고정 화면 width를 강제하지 않는다. +- `전체` 버튼 height는 크리에이터 item 높이와 동일하게 표시한다. +- `전체` 버튼은 `paddingHorizontal = 16`을 적용한다. +- `전체` 버튼 text color는 `Soda/400`이며, 코드에서는 `Color.soda400`을 사용한다. +- `전체` 버튼 탭 시 기존에 만들어진 전체 팔로잉 목록 화면으로 이동한다. + +### 7.4 On Air 섹션 + +요구사항: +- `onAirLives`가 비어 있으면 섹션을 숨긴다. +- 섹션 타이틀은 Figma와 동일하게 `On Air`를 사용한다. +- 각 item은 크리에이터 프로필 이미지, 닉네임, 라이브 제목, 시작 후 경과 시간을 표시한다. +- `startedAtUtc`는 UTC 기준 문자열로 받아 디바이스 timezone 기준 상대 시간 또는 경과 시간으로 표시한다. +- item 탭 시 `liveId`로 기존 라이브 입장 흐름을 사용한다. +- 라이브 입장 전 로그인 guard, 라이브룸 외부 이동 확인, 결제/비밀번호 dialog 등은 기존 `MainView`의 `handleRecommendationLiveTap`과 `LiveViewModel.enterLiveRoom(roomId:)` 패턴을 재사용한다. +- 카드 루트는 부모 가로 스크롤/컨테이너에 맞춰 크기를 잡고, 필요한 최소 높이 외에는 고정 width/height를 피한다. + +### 7.5 최근 대화 섹션 + +요구사항: +- `recentChats`가 비어 있으면 섹션을 숨긴다. +- 섹션 타이틀은 `최근 대화`를 사용한다. +- `ChatRoomListItemResponse.roomId`를 item id와 채팅방 진입 id로 사용한다. +- `chatType`은 서버 문자열 그대로 디코딩하고, 현재 화면에서는 별도 tag 표시 요구가 없으면 노출하지 않는다. +- `targetName`은 채팅방 제목으로 표시한다. +- `targetImageUrl`은 채팅방 이미지로 표시한다. +- `lastMessage`는 최근 메시지 preview로 표시한다. +- `lastMessageAt`은 UTC 기준 문자열로 받아 디바이스 timezone 기준 상대 시간 또는 시간 label로 표시한다. +- item 탭 시 `roomId`로 기존 채팅방 진입 흐름을 사용한다. +- 채팅방 진입에 로그인 guard가 필요하면 기존 메인/채팅 탭의 guard 패턴을 재사용한다. + +### 7.6 이달의 스케줄 섹션 + +요구사항: +- `monthlySchedules`가 비어 있으면 섹션을 숨긴다. +- 섹션 타이틀은 `이달의 스케줄`을 사용한다. +- 각 item은 날짜 영역, 크리에이터 프로필/닉네임, 제목, 활동 타입 tag, 시간 또는 `On Air` 상태를 표시한다. +- `scheduledAtUtc`는 UTC 기준 문자열로 받아 디바이스 timezone 기준 월/일/요일/시간으로 표시한다. +- 오늘 날짜인 item은 Figma처럼 날짜 영역에 `오늘`을 표시한다. +- `isOnAir == true`이면 우측 상태를 `On Air`로 표시하고 `Color.soda400`을 사용한다. +- `isOnAir == true`인 item은 좌측 accent line 또는 동등한 강조 표현을 Figma 기준으로 표시한다. +- `type`은 `CreatorActivityType`으로 디코딩한다. +- `CreatorActivityType` 서버 값은 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY` 네 가지를 지원한다. +- 기존 `RecommendedActivityType`과 값 범위가 같으므로 중복을 줄일 수 있으면 공용 enum으로 정리하되, 변경 범위가 커지면 팔로잉 전용 enum으로 둔다. +- item 탭 시 `type`과 `targetId`에 맞춰 라이브, 오디오 콘텐츠, 커뮤니티 등 기존 상세 진입 흐름을 사용한다. + +### 7.7 최근 소식 섹션 + +요구사항: +- `recentNews`가 비어 있으면 섹션을 숨긴다. +- 섹션 타이틀은 `최근 소식`을 사용한다. +- 모든 최근 소식 item의 표시 시간은 `visibleFromAtUtc`를 기준으로 한다. +- 알 수 없는 `FollowingNewsType`은 앱 crash 없이 item을 숨기거나 기본 텍스트형 카드로 표시한다. 기본 정책은 숨김으로 둔다. + +타입별 UI: +- `CREATOR_RANKING`, `CONTENT_RANKING` + - Figma `node-id=24-5717` 기준 랭킹 소식 카드로 표시한다. + - `CREATOR_RANKING`은 `creatorRanking` payload를 사용한다. + - `CONTENT_RANKING`은 `contentRanking` payload를 사용한다. + - `rank` 값을 본문에서 `N위`로 표시하고 `Color.soda400`로 강조한다. + - 타입과 대응되는 payload가 `nil`이면 해당 item은 숨긴다. + - `CREATOR_RANKING` 탭 시 `creatorRanking.creatorId`로 크리에이터 상세로 이동한다. + - `CONTENT_RANKING` 탭 시 `contentRanking.contentId`로 콘텐츠 상세로 이동한다. +- `COMMUNITY_POST` + - 기존 `CommunityPostCard` 또는 기존 커뮤니티 Feed View를 재사용한다. + - `communityPost` payload를 사용한다. + - `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, `createdAt`, `likeCount`, `commentCount`를 표시한다. + - 기존 `CommunityPostCard`를 재사용할 경우 `audioUrl = nil`, `price = 0`, `existOrdered = true`로 매핑해 표시 전용으로 사용한다. + - 타입과 대응되는 payload가 `nil`이면 해당 item은 숨긴다. + - 현재 기존 커뮤니티 게시글 직접 진입 구조는 `creatorId`를 요구하지만 `FollowingCommunityPostNewsResponse`에는 `creatorId`가 없다. + - `postId` 단독 상세 route/API가 없으면 잘못된 creator 화면이나 수정 화면으로 이동하지 않는다. + - 서버 응답에 `creatorId`가 추가되거나 `postId` 단독 route/API가 확인되면 `communityPost.postId` 기준 커뮤니티 게시글 진입 흐름을 연결한다. +- `AUDIO_CONTENT` + - Figma `node-id=1229-27212` 기준 오디오 콘텐츠 소식 카드로 표시한다. + - `audioContent` payload를 사용한다. + - 썸네일은 정사각형 비율을 기본으로 하고, `contentImageUrl`이 없으면 기존 콘텐츠 placeholder를 사용한다. + - `creatorProfileImageUrl`, `creatorNickname`, `title`을 표시한다. + - tag는 `오디오`를 표시한다. + - 타입과 대응되는 payload가 `nil`이면 해당 item은 숨긴다. + - item 탭 시 `audioContent.contentId`로 콘텐츠 상세 화면에 이동한다. +- `PHOTO_CONTENT` + - Figma `node-id=1229-27213` 기준 화보 콘텐츠 소식 카드로 표시한다. + - `photoContent` payload를 사용한다. + - 썸네일은 세로형 비율을 기본으로 하고, `contentImageUrl`이 없으면 기존 콘텐츠 placeholder를 사용한다. + - `creatorProfileImageUrl`, `creatorNickname`, `title`을 표시한다. + - tag는 `화보`를 표시한다. + - 타입과 대응되는 payload가 `nil`이면 해당 item은 숨긴다. + - item 탭 시 `photoContent.contentId`로 콘텐츠 상세 화면에 이동한다. + +### 7.8 상세 진입 guard + +- 팔로잉 탭 선택 자체에 로그인 guard를 적용한다. +- 팔로잉 API 호출과 화면 노출은 탭 선택 guard를 통과한 뒤 서버의 `isLoginRequired` 응답도 함께 따른다. +- 각 item 탭으로 상세 화면에 진입할 때는 기존 `MainView`의 `performRecommendationDetailAction` 계열 로그인 guard를 재사용하거나 팔로잉용 callback을 같은 수준에서 추가한다. +- 토큰이 비어 있으면 `AppState.shared.setAppStep(step: .login)`으로 이동한다. +- 라이브 진입은 기존 `LiveViewModel.enterLiveRoom(roomId:)` 흐름을 재사용한다. +- 콘텐츠, 크리에이터, 커뮤니티, 채팅 이동은 기존 `AppStep` 라우팅을 재사용한다. +- 단, `COMMUNITY_POST`는 `postId` 단독 진입 가능 route가 확인되기 전까지 표시만 제공하고 상세 진입을 연결하지 않는다. +- 서버 응답에 없는 성인/민감 콘텐츠 여부를 클라이언트에서 임의 추정하지 않는다. + +## 8. UX / UI Expectations + +### 8.1 포함하는 UI +- 홈 공통 title bar +- 홈 상단 `추천`/`랭킹`/`팔로잉` Text tab bar +- 선택된 `팔로잉` 탭 콘텐츠 +- 팔로잉 크리에이터 가로 목록과 마지막 `전체` 버튼 +- `On Air` 가로 라이브 목록 +- 최근 대화 목록 +- 이달의 스케줄 목록 +- 최근 소식 타입별 카드 목록 +- 하단 메인 tab bar는 기존 `MainView` 구조에서 유지 + +### 8.2 시각 규칙 +- 선택된 홈 상단 탭 `팔로잉`은 흰색 bold text로 표시한다. +- 비선택 탭 `추천`, `랭킹`은 gray text로 표시한다. +- 전체 화면 배경은 기존 V2 홈과 동일하게 검정 계열을 유지한다. +- 섹션 타이틀은 기존 `SectionTitle` 재사용을 우선한다. +- 카드 배경은 Figma 기준 `Gray/900` 계열이며, 코드에서는 `Color.gray900`을 사용한다. +- 강조 텍스트와 `On Air`, `전체` 버튼 텍스트는 `Color.soda400`을 사용한다. +- nickname, title, body가 길면 영역을 넘지 않도록 한 줄 또는 카드별 제한 줄수에서 말줄임 처리한다. +- 루트 container와 섹션 container에는 불필요한 고정 숫자 `frame(width:)`, `frame(height:)`를 사용하지 않는다. +- 고정 크기가 필요한 경우는 프로필 이미지, 아이콘, tag, 최소 터치 영역처럼 UI 의미상 크기가 정해진 요소로 제한한다. + +### 8.3 Empty 처리 +- `isLoginRequired == true`이면 로그인 필요 상태를 표시한다. +- `isLoginRequired == false`이고 개별 섹션 배열이 비어 있으면 해당 섹션은 숨긴다. +- 모든 섹션 배열이 비어 있으면 팔로잉 탭 전체 empty state를 표시한다. +- 전체 empty state 문구는 `팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.`로 표시한다. +- API 실패 문구는 `팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.`로 표시한다. + +## 9. Reusable Component Candidates + +사전 확인한 V2 재사용 후보: +- `SodaLive/Sources/V2/Component/SectionTitle.swift` + - 섹션 타이틀 재사용 후보. +- `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` + - `COMMUNITY_POST` 표시 재사용 후보. 단, 현재 card 입력값이 팔로잉 최근 소식 응답보다 많으므로 표시 전용 adapter 또는 variant 필요 여부를 plan-task에서 확정한다. +- `SodaLive/Sources/V2/Component/AudioContentCard.swift` + - 오디오 콘텐츠 카드 후보. 단, 현재 `AudioContentCardSize.width`가 고정값을 반환하므로 이번 요구사항의 “불필요한 고정 width/height 금지”와 충돌할 수 있다. 최근 소식의 행형 오디오 카드는 별도 컴포넌트가 더 적합할 수 있다. +- `SodaLive/Sources/V2/Main/Home/Recommendation/Repository/MainHomeRecommendationApi.swift` + - V2 홈 API `TargetType` 구성과 optional token header 패턴 참조. +- `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift` + - `ApiResponse` 디코딩, `@StateObject`, 최초 로딩 패턴 참조. +- `SodaLive/Sources/V2/Main/Home/Ranking/**` + - 탭 전용 API/Repository/ViewModel/Components 분리 패턴 참조. +- `SodaLive/Sources/Chat/Talk/TalkRoom.swift` + - 최근 대화 진입 흐름과 기존 채팅방 화면 패턴 확인 후보. + +## 10. Technical Constraints +- 신규 팔로잉 탭 관련 파일은 `SodaLive/Sources/V2/Main/Home/Following/**` 아래에 둔다. +- 여러 페이지에서 재사용 가능한 신규 공용 컴포넌트는 `SodaLive/Sources/V2/Component/**` 아래에 형태별 폴더로 둔다. +- 팔로잉 탭 내부에서만 사용하는 컴포넌트는 `SodaLive/Sources/V2/Main/Home/Following/Components/**` 아래에 둔다. +- `MainHomeView`에는 API 호출, response 변환, 팔로잉 세부 UI를 넣지 않는다. +- 기존 `MainHomeRecommendationView`, `MainHomeRankingView` 동작을 변경하지 않는다. +- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다. +- 외부 라이브러리를 추가하지 않는다. +- Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다. +- 다국어가 필요한 신규 문구는 `I18n`에 추가한다. +- 날짜/시간은 기존 `DateParser` 또는 프로젝트의 날짜 유틸을 우선 사용한다. +- API 인증 헤더는 기존 V2 홈 API 패턴을 따르되, 토큰이 비어 있을 수 있음을 고려한다. + +## 11. Plan-task Phase 기준 +이 PRD를 바탕으로 `plan-task.md`를 생성할 때는 Figma Design의 각 섹션을 1개의 Phase로 분리한다. + +권장 Phase: +1. Phase 1: 현재 구조 확인과 팔로잉 탭 기반 계층 준비 +2. Phase 2: API, Repository, Response model, ViewModel +3. Phase 3: 팔로잉 크리에이터 섹션과 `전체` 버튼 +4. Phase 4: On Air 섹션 +5. Phase 5: 최근 대화 섹션 +6. Phase 6: 이달의 스케줄 섹션 +7. Phase 7: 최근 소식 랭킹 카드 +8. Phase 8: 최근 소식 커뮤니티 카드 +9. Phase 9: 최근 소식 오디오/화보 콘텐츠 카드 +10. Phase 10: 로그인/empty/error 상태와 상세 진입 callback 연결 +11. Phase 11: 프로젝트 등록, 빌드, UI 검증 + +각 phase는 대상 파일 경로와 검증 기준을 포함한다. 구현 중 phase 완료 시 `plan-task.md` 체크박스를 즉시 갱신한다. + +## 12. Success Criteria +- `팔로잉` 탭 진입 시 `GET /api/v2/home/following` 호출이 발생한다. +- 비로그인 상태에서 홈 상단 `팔로잉` 탭을 터치하면 `팔로잉` 탭으로 전환하지 않고 로그인 화면으로 이동한다. +- 로그인 상태에서 홈 상단 `팔로잉` 탭을 터치하면 `팔로잉` 탭으로 전환한다. +- 응답이 `ApiResponse`로 디코딩된다. +- `isLoginRequired == true`이면 로그인 필요 상태가 표시되고 로그인 화면으로 이동할 수 있다. +- `isLoginRequired == false`이면 Figma 순서대로 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 최근 소식 섹션이 표시된다. +- 비어 있는 섹션은 표시되지 않는다. +- 모든 섹션 데이터가 비어 있으면 전체 empty state가 표시된다. +- 팔로잉 크리에이터 가로 목록 마지막에는 항상 `전체` 버튼이 표시된다. +- `전체` 버튼은 크리에이터 item 높이와 동일하고, horizontal padding 16, `Color.soda400` 텍스트를 사용한다. +- 최근 소식의 `CREATOR_RANKING`은 `creatorRanking` payload로 랭킹 소식 카드가 표시된다. +- 최근 소식의 `CONTENT_RANKING`은 `contentRanking` payload로 랭킹 소식 카드가 표시된다. +- 최근 소식의 `COMMUNITY_POST`는 `communityPost` payload로 기존 커뮤니티 Feed View 또는 표시 전용 variant가 표시된다. +- `COMMUNITY_POST`는 `postId` 단독 진입 가능 route가 확인되기 전까지 잘못된 화면 이동을 하지 않는다. +- 최근 소식의 `AUDIO_CONTENT`는 `audioContent` payload로 Figma 오디오 콘텐츠 카드 형태가 표시된다. +- 최근 소식의 `PHOTO_CONTENT`는 `photoContent` payload로 Figma 화보 콘텐츠 카드 형태가 표시된다. +- 최근 소식 시간 표시는 `visibleFromAtUtc` 기준으로 표시된다. +- item 탭 시 타입별 기존 상세 진입 흐름을 사용한다. +- 루트 화면과 섹션 container에는 불필요한 고정 숫자 width/height가 없다. +- `MainHomeView`는 탭 shell 역할만 유지하고 팔로잉 탭 세부 구현은 `Following` 하위로 분리된다. + +## 13. Open Questions +해당 없음. + +## 14. Verification Notes +- `docs/agent-guides/documentation-policy.md`를 확인해 신규 PRD 경로와 필수 섹션 기준을 검증했다. +- `docs/prd/sample-prd.md`, `docs/20260602_메인_홈_추천_UI_API_연동/prd.md`, `docs/20260630_메인_홈_랭킹_탭/prd.md`를 확인해 저장소의 PRD 작성 스타일을 맞췄다. +- `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift`가 현재 placeholder임을 확인했다. +- `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`가 팔로잉 탭에 `MainHomeFollowingView()`를 조합하고 있음을 확인했다. +- `SodaLive/Sources/V2/Main/Home/Recommendation/**`와 `SodaLive/Sources/V2/Main/Home/Ranking/**`의 탭 전용 API/Repository/ViewModel/Components 분리 패턴을 확인했다. +- Figma `node-id=24-5682`의 design context와 screenshot을 확인해 팔로잉 탭의 섹션 순서와 최근 소식 타입별 카드 형태를 검증했다. +- Figma `node-id=24-5717`, `1229-27212`, `1229-27213`이 각각 랭킹 소식, 오디오 콘텐츠 소식, 화보 콘텐츠 소식 카드에 해당함을 확인했다. +- `SodaLive/Sources/V2/Component/SectionTitle.swift`, `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`, `SodaLive/Sources/V2/Component/AudioContentCard.swift`를 확인해 재사용 후보와 제약을 PRD에 반영했다. +- `SodaLive/Sources/UI/Theme/Color.swift`에서 `Color.soda400`이 `#00BDF7`임을 확인했다. +- 사용자 답변을 반영해 `ChatRoomListItemResponse`, `CreatorActivityType`, nested 최근 소식 response 구조, `visibleFromAtUtc` 시간 표시 기준, 기존 전체 팔로잉 목록 화면 재사용, 팔로잉 탭 선택 시 로그인 guard 정책을 확정 요구사항으로 이동했다. +- empty state 문구를 `팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.`, API 실패 문구를 `팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.`로 확정했다. +- 기존 커뮤니티 게시글 직접 진입 구조가 `creatorId`를 요구하는 반면 `FollowingCommunityPostNewsResponse`에는 `creatorId`가 없으므로, `COMMUNITY_POST` tap은 `postId` 단독 route/API 확인 전까지 잘못된 화면 이동을 구현하지 않는 제약을 반영했다.