30 KiB
30 KiB
PRD: 메인 홈 팔로잉 탭
1. Overview
메인 홈 화면의 팔로잉 탭에서 신규 API GET /api/v2/home/following 응답을 사용해 팔로잉 크리에이터, On Air 라이브, 최근 대화, 이달의 스케줄, 최근 소식을 표시한다.
기존 MainHomeView는 홈 상단 공통 shell과 추천/랭킹/팔로잉 탭 전환만 담당한다. 팔로잉 탭의 API 호출, 상태 관리, 화면 조립은 SodaLive/Sources/V2/Main/Home/Following/** 하위에서 처리한다.
Figma 전체 화면 기준 섹션 순서는 아래를 따른다.
- 팔로잉 크리에이터
- On Air
- 최근 대화
- 이달의 스케줄
- 최근 소식
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<HomeFollowingTabResponse>디코딩을 우선한다.
Response
data class HomeFollowingTabResponse(
val isLoginRequired: Boolean,
val followingCreators: List<FollowingCreatorResponse>,
val onAirLives: List<FollowingLiveResponse>,
val recentChats: List<ChatRoomListItemResponse>,
val monthlySchedules: List<FollowingScheduleResponse>,
val recentNews: List<FollowingNewsResponse>
)
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 모델 기준
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은creatorRankingpayload를 사용한다.CONTENT_RANKING은contentRankingpayload를 사용한다.rank값을 본문에서N위로 표시하고Color.soda400로 강조한다.- 타입과 대응되는 payload가
nil이면 해당 item은 숨긴다. CREATOR_RANKING탭 시creatorRanking.creatorId로 크리에이터 상세로 이동한다.CONTENT_RANKING탭 시contentRanking.contentId로 콘텐츠 상세로 이동한다.
- Figma
COMMUNITY_POST- 기존
CommunityPostCard또는 기존 커뮤니티 Feed View를 재사용한다. communityPostpayload를 사용한다.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기준 오디오 콘텐츠 소식 카드로 표시한다. audioContentpayload를 사용한다.- 썸네일은 정사각형 비율을 기본으로 하고,
contentImageUrl이 없으면 기존 콘텐츠 placeholder를 사용한다. creatorProfileImageUrl,creatorNickname,title을 표시한다.- tag는
오디오를 표시한다. - 타입과 대응되는 payload가
nil이면 해당 item은 숨긴다. - item 탭 시
audioContent.contentId로 콘텐츠 상세 화면에 이동한다.
- Figma
PHOTO_CONTENT- Figma
node-id=1229-27213기준 화보 콘텐츠 소식 카드로 표시한다. photoContentpayload를 사용한다.- 썸네일은 세로형 비율을 기본으로 하고,
contentImageUrl이 없으면 기존 콘텐츠 placeholder를 사용한다. creatorProfileImageUrl,creatorNickname,title을 표시한다.- tag는
화보를 표시한다. - 타입과 대응되는 payload가
nil이면 해당 item은 숨긴다. - item 탭 시
photoContent.contentId로 콘텐츠 상세 화면에 이동한다.
- Figma
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.swiftCOMMUNITY_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 패턴 참조.
- V2 홈 API
SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swiftApiResponse<T>디코딩,@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:
- Phase 1: 현재 구조 확인과 팔로잉 탭 기반 계층 준비
- Phase 2: API, Repository, Response model, ViewModel
- Phase 3: 팔로잉 크리에이터 섹션과
전체버튼 - Phase 4: On Air 섹션
- Phase 5: 최근 대화 섹션
- Phase 6: 이달의 스케줄 섹션
- Phase 7: 최근 소식 랭킹 카드
- Phase 8: 최근 소식 커뮤니티 카드
- Phase 9: 최근 소식 오디오/화보 콘텐츠 카드
- Phase 10: 로그인/empty/error 상태와 상세 진입 callback 연결
- Phase 11: 프로젝트 등록, 빌드, UI 검증
각 phase는 대상 파일 경로와 검증 기준을 포함한다. 구현 중 phase 완료 시 plan-task.md 체크박스를 즉시 갱신한다.
12. Success Criteria
팔로잉탭 진입 시GET /api/v2/home/following호출이 발생한다.- 비로그인 상태에서 홈 상단
팔로잉탭을 터치하면팔로잉탭으로 전환하지 않고 로그인 화면으로 이동한다. - 로그인 상태에서 홈 상단
팔로잉탭을 터치하면팔로잉탭으로 전환한다. - 응답이
ApiResponse<HomeFollowingTabResponse>로 디코딩된다. isLoginRequired == true이면 로그인 필요 상태가 표시되고 로그인 화면으로 이동할 수 있다.isLoginRequired == false이면 Figma 순서대로 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 최근 소식 섹션이 표시된다.- 비어 있는 섹션은 표시되지 않는다.
- 모든 섹션 데이터가 비어 있으면 전체 empty state가 표시된다.
- 팔로잉 크리에이터 가로 목록 마지막에는 항상
전체버튼이 표시된다. 전체버튼은 크리에이터 item 높이와 동일하고, horizontal padding 16,Color.soda400텍스트를 사용한다.- 최근 소식의
CREATOR_RANKING은creatorRankingpayload로 랭킹 소식 카드가 표시된다. - 최근 소식의
CONTENT_RANKING은contentRankingpayload로 랭킹 소식 카드가 표시된다. - 최근 소식의
COMMUNITY_POST는communityPostpayload로 기존 커뮤니티 Feed View 또는 표시 전용 variant가 표시된다. COMMUNITY_POST는postId단독 진입 가능 route가 확인되기 전까지 잘못된 화면 이동을 하지 않는다.- 최근 소식의
AUDIO_CONTENT는audioContentpayload로 Figma 오디오 콘텐츠 카드 형태가 표시된다. - 최근 소식의
PHOTO_CONTENT는photoContentpayload로 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_POSTtap은postId단독 route/API 확인 전까지 잘못된 화면 이동을 구현하지 않는 제약을 반영했다.