diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 7a255b52..963463c3 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -3165,6 +3165,14 @@ If you block this user, the following features will be restricted. enum CreatorChannelLive { static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") } static var startLive: String { pick(ko: "라이브 시작하기", en: "Start live", ja: "ライブを開始") } + static var emptyMessage: String { + pick( + ko: "크리에이터가 라이브를 준비중입니다.\n기대해 주세요!", + en: "The creator is preparing a live.\nPlease stay tuned!", + ja: "クリエイターがライブを準備中です。\nお楽しみに!" + ) + } + enum Sort { static var latest: String { pick(ko: "최신순", en: "Latest", ja: "最新順") } static var popular: String { pick(ko: "인기순", en: "Popular", ja: "人気順") } diff --git a/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift b/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift index 18df8f6c..9e618da0 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift @@ -28,38 +28,56 @@ struct CreatorChannelLiveTabView: View { private var content: some View { VStack(spacing: 0) { - CreatorChannelSortBar( - selectedSort: viewModel.selectedSort, - onTapSort: { - isSortSheetPresented = true - } - ) - - LazyVStack(spacing: 0) { - CreatorChannelCurrentLiveSection( - currentLive: viewModel.response?.currentLive, - onTapLive: onTapLive + if isEmptyState { + emptyStateView + } else { + CreatorChannelSortBar( + selectedSort: viewModel.selectedSort, + onTapSort: { + isSortSheetPresented = true + } ) - .padding(.top, SodaSpacing.s14) - .padding(.bottom, SodaSpacing.s12) - ForEach(viewModel.liveReplayContents) { audioContent in - CreatorChannelAudioContentListItem(audioContent: audioContent) { - onTapContent(audioContent.audioContentId) + LazyVStack(spacing: 0) { + CreatorChannelCurrentLiveSection( + currentLive: viewModel.response?.currentLive, + onTapLive: onTapLive + ) + .padding(.top, SodaSpacing.s14) + .padding(.bottom, SodaSpacing.s12) + + ForEach(viewModel.liveReplayContents) { audioContent in + CreatorChannelAudioContentListItem(audioContent: audioContent) { + onTapContent(audioContent.audioContentId) + } + .onAppear { + viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: audioContent) + } } - .onAppear { - viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: audioContent) + + if viewModel.isLoadingNextPage { + ProgressView() + .padding(.vertical, SodaSpacing.s20) } } - - if viewModel.isLoadingNextPage { - ProgressView() - .padding(.vertical, SodaSpacing.s20) - } + .padding(.bottom, isOwnCreatorChannel ? 70 : SodaSpacing.s20) } - .padding(.bottom, isOwnCreatorChannel ? 70 : SodaSpacing.s20) } } + + private var isEmptyState: Bool { + guard viewModel.hasLoaded, let response = viewModel.response else { return false } + return response.currentLive == nil && response.liveReplayContentCount == 0 + } + + private var emptyStateView: some View { + Text(I18n.CreatorChannelLive.emptyMessage) + .appFont(.body3) + .foregroundColor(Color.gray500) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.vertical, 48) + } } struct CreatorChannelLiveTabView_Previews: PreviewProvider { diff --git a/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md b/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md index 4b20a914..28d5636d 100644 --- a/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md +++ b/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md @@ -232,6 +232,19 @@ - 실행 명령: `rg "struct CreatorChannelLiveTabView|CreatorChannelLiveViewModel|fetchFirstPage\\(creatorId: creatorId\\)|CreatorChannelSortBar|CreatorChannelSortBottomSheet|CreatorChannelCurrentLiveSection|CreatorChannelAudioContentListItem|fetchNextPageIfNeeded|onTapContent\\(audioContent.audioContentId\\)|isOwnCreatorChannel" SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift` - 기대 결과: 라이브 탭 화면, 정렬, 현재 라이브, 다시듣기 목록, pagination이 확인된다. +- [x] **Task 4.4: 라이브 탭 Empty 상태 추가** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift` + - 수정: `SodaLive/Sources/I18n/I18n.swift` + - 작업 내용: + - `currentLive == nil`이고 `liveReplayContentCount == 0`이면 Empty 상태를 표시한다. + - Empty 상태에서는 `CreatorChannelSortBar`, `CreatorChannelCurrentLiveSection`, 다시듣기 list를 숨긴다. + - Empty 문구는 `I18n.CreatorChannelLive.emptyMessage`를 사용하고 `크리에이터가 라이브를 준비중입니다.\n기대해 주세요!`로 표시한다. + - Empty 문구 영역에는 vertical padding `48pt`를 적용한다. + - 검증 기준: + - 실행 명령: `rg "emptyMessage|isEmptyState|padding\(\.vertical, 48\)|CreatorChannelSortBar" SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift SodaLive/Sources/I18n/I18n.swift` + - 기대 결과: Empty 조건, 문구, vertical padding, sort-bar 숨김 분기가 확인된다. + ### Phase 5: CreatorChannelView 연결 - [x] **Task 5.1: 라이브 탭 콘텐츠 연결** @@ -357,3 +370,4 @@ - 2026-07-04: Phase 7.3 수동 동작 검증은 blocked. iOS Simulator는 사용 가능하나 실제 로그인 세션, 타인/본인 크리에이터 채널 테스트 데이터, 라이브/다시듣기 응답 데이터, 네트워크 요청 관찰 수단이 없어 CLI 환경에서 PRD Success Criteria 전체를 완료 검증하지 않음. - 2026-07-04: 리뷰 반영. sort-bar 카운트 표시 요구사항이 제거되어 PRD/계획서를 현재 정렬 표시 기준으로 갱신함. - 2026-07-04: 리뷰 반영. 본인 채널 라이브 시작하기 CTA를 `CreatorChannelLiveTabView` 내부 scroll content overlay에서 `CreatorChannelView` 최상위 overlay로 이동해 화면 하단 고정 구조로 변경함. +- 2026-07-04: 요구사항 추가 반영. 진행 중인 라이브가 없고 라이브 다시듣기 콘텐츠가 0개인 경우 sort-bar/list를 숨기고 Empty 문구만 표시하도록 PRD/계획/구현을 갱신함. diff --git a/docs/20260703_크리에이터_채널_라이브_탭/prd.md b/docs/20260703_크리에이터_채널_라이브_탭/prd.md index 52a8f1ff..db1f4cc5 100644 --- a/docs/20260703_크리에이터_채널_라이브_탭/prd.md +++ b/docs/20260703_크리에이터_채널_라이브_탭/prd.md @@ -133,7 +133,9 @@ enum ContentSort: String, Decodable, CaseIterable { - 동일 화면 생명주기에서 같은 `creatorId`, `sort`, 첫 페이지 데이터가 이미 있으면 불필요한 중복 호출을 피한다. - 정렬 변경 시 첫 페이지부터 다시 조회하고, 조회 성공 시에만 기존 `liveReplayContents`를 새 정렬 결과로 교체한다. - API 실패 시 크리에이터 채널 공통 shell은 유지하고, 이미 표시 중인 `liveReplayContents`는 유지한 채 기존 V2 패턴의 오류/빈 상태를 표시한다. -- `liveReplayContentCount == 0`이고 `currentLive == nil`이면 목록 empty state를 표시한다. +- `liveReplayContentCount == 0`이고 `currentLive == nil`이면 Empty 상태를 표시한다. +- Empty 상태에서는 sort-bar와 다시듣기 list를 모두 숨기고 `크리에이터가 라이브를 준비중입니다.\n기대해 주세요!` 문구만 표시한다. +- Empty 문구 영역은 vertical padding `48pt`를 적용한다. ### 7.3 Sort-bar - 생성 위치: `SodaLive/Sources/V2/CreatorChannel/Components/**` @@ -208,6 +210,7 @@ enum ContentSort: String, Decodable, CaseIterable { - 라이브 탭 선택 시 tab-bar indicator는 기존 `CreatorChannelTabBar`의 선택 상태를 따른다. - 라이브 탭 콘텐츠 배경은 기존 크리에이터 채널 dark theme와 맞춘다. - sort-bar는 tab-bar 아래에 붙어 보이고, 목록 콘텐츠와 겹치지 않아야 한다. +- Empty 상태에서는 sort-bar와 list가 보이지 않아야 한다. - 다시듣기 item의 썸네일, 제목, 가격/상태 영역은 긴 제목과 다국어 문구에서도 겹치지 않아야 한다. - BottomSheet는 dim 또는 drag/dismiss 동작이 기존 앱 sheet 패턴과 일관되어야 한다. - 로딩 중에는 기존 목록을 과도하게 흔들지 않고, 첫 페이지 로딩과 추가 페이지 로딩 상태를 구분한다. 목록 교체와 append는 조회 성공 시에만 수행한다. @@ -229,6 +232,7 @@ enum ContentSort: String, Decodable, CaseIterable { - sort-bar 터치 시 BottomSheet가 표시되고 `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 4개 옵션만 보인다. - 정렬 변경 시 첫 페이지부터 다시 조회되고, 조회 성공 시에만 목록이 새 정렬 결과로 교체된다. - `currentLive != nil`이면 홈 탭과 동일한 현재 라이브 UI가 라이브 탭에 표시된다. +- `currentLive == nil`이고 `liveReplayContentCount == 0`이면 sort-bar/list 없이 `크리에이터가 라이브를 준비중입니다.\n기대해 주세요!` Empty 문구만 표시된다. - 라이브 다시듣기 item에 가격, 포인트 사용 가능, 19금, 소장중, 대여중 상태가 조건에 맞게 표시된다. - `hasNext == true` 상태에서 하단 스크롤 시 `page + 1` 페이지가 호출되고, 조회 성공 시에만 기존 목록 뒤에 append된다. - `hasNext == false`이면 추가 페이지 호출을 하지 않는다.