From 43e32f1867a0224c7419c5d276dcc6236ccb0df7 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 20 Jul 2026 17:31:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(live):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Sources/ContentView.swift | 32 ++--- .../Live/Room/Detail/LiveDetailView.swift | 16 +++ .../CreatorChannel/CreatorChannelView.swift | 4 +- SodaLive/Sources/V2/Main/MainView.swift | 4 +- docs/20260701_크리에이터_채널_홈/plan-task.md | 129 +++++++++++++++++- docs/20260701_크리에이터_채널_홈/prd.md | 27 +++- 6 files changed, 190 insertions(+), 22 deletions(-) diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 89fe83b2..fea56cb8 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -30,21 +30,6 @@ struct ContentView: View { .navigationBarBackButtonHidden(true) } - if let liveDetailSheet = appState.liveDetailSheet { - LiveDetailView( - roomId: liveDetailSheet.roomId, - onClickParticipant: liveDetailSheet.onClickParticipant, - onClickReservation: liveDetailSheet.onClickReservation, - onClickStart: liveDetailSheet.onClickStart, - onClickCancel: liveDetailSheet.onClickCancel, - onClickClose: { - withAnimation { - appState.hideLiveDetailSheet() - } - } - ) - } - if isShowDialog { SodaDialog( title: I18n.Common.pointGrantTitle, @@ -62,7 +47,6 @@ struct ContentView: View { self.isShowDialog = true } } - .sodaToast(isPresented: $appState.isShowErrorPopup, message: appState.errorMessage, autohideIn: 1) .navigationDestination(for: AppRoute.self) { route in if let step = appState.appStep(for: route) { AppStepLayerView(step: step, canPgPaymentViewModel: canPgPaymentViewModel) @@ -75,6 +59,21 @@ struct ContentView: View { } .overlay { ZStack { + if let liveDetailSheet = appState.liveDetailSheet { + LiveDetailView( + roomId: liveDetailSheet.roomId, + onClickParticipant: liveDetailSheet.onClickParticipant, + onClickReservation: liveDetailSheet.onClickReservation, + onClickStart: liveDetailSheet.onClickStart, + onClickCancel: liveDetailSheet.onClickCancel, + onClickClose: { + withAnimation { + appState.hideLiveDetailSheet() + } + } + ) + } + if appState.isShowPlayer { LiveRoomViewV2() } @@ -84,6 +83,7 @@ struct ContentView: View { } } } + .sodaToast(isPresented: $appState.isShowErrorPopup, message: appState.errorMessage, autohideIn: 1) } private var leaveLiveNavigationDialog: some View { diff --git a/SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift b/SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift index 206c2ea1..275d8f11 100644 --- a/SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift +++ b/SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift @@ -272,6 +272,10 @@ struct LiveDetailView: View { .onAppear { viewModel.getDetail(roomId: roomId) } + .onChange(of: viewModel.isShowPopup) { isShowing in + guard isShowing, viewModel.room == nil else { return } + handleDetailLoadFailure() + } } @ViewBuilder @@ -473,6 +477,18 @@ struct LiveDetailView: View { } } + private func handleDetailLoadFailure() { + let message = viewModel.errorMessage + viewModel.isShowPopup = false + AppState.shared.isShowErrorPopup = false + hideView() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + AppState.shared.errorMessage = message + AppState.shared.isShowErrorPopup = true + } + } + private func makeSnsItems(from manager: GetRoomDetailManager) -> [LiveDetailSnsItem] { var items = [LiveDetailSnsItem]() diff --git a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift index af55720d..717e36bf 100644 --- a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift @@ -506,9 +506,9 @@ struct CreatorChannelView: View { guard targetId > 0 else { return } switch type { - case .live, .liveReplay: + case .live: showLiveDetail(targetId) - case .audio: + case .audio, .liveReplay: showContentDetail(targetId) case .community: showCommunity(targetId) diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index 9dce86e1..5ca2f2cc 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -496,9 +496,9 @@ struct MainView: View { guard targetId > 0 else { return } switch type { - case .live, .liveReplay: + case .live: handleRecommendationLiveTap(roomId: targetId) - case .audio: + case .audio, .liveReplay: handleRecommendationContentTap(contentId: targetId) case .community: performRecommendationDetailAction { diff --git a/docs/20260701_크리에이터_채널_홈/plan-task.md b/docs/20260701_크리에이터_채널_홈/plan-task.md index d7982d42..089815f2 100644 --- a/docs/20260701_크리에이터_채널_홈/plan-task.md +++ b/docs/20260701_크리에이터_채널_홈/plan-task.md @@ -425,7 +425,7 @@ - 작업 내용: - `schedules.isEmpty`이면 섹션을 표시하지 않는다. - 각 item의 `scheduledAtUtc`, `title`, `type`, `targetId`를 표시한다. - - tap action은 closure로 분리하고, `.live`, `.liveReplay`, `.audio`, `.community` 분기는 기존 `MainView.handleFollowingScheduleTap`과 같은 기준을 따른다. + - tap action은 closure로 분리하고, `.live`는 라이브 상세, `.liveReplay`와 `.audio`는 동일한 오디오 콘텐츠 상세, `.community`는 커뮤니티 화면으로 분기한다. - `.unknown`은 crash 없이 무시한다. - 검증 기준: - 실행 명령: `rg "struct CreatorChannelScheduleSection|schedules|scheduledAtUtc|CreatorActivityType|targetId|unknown|onTapSchedule" SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelScheduleSection.swift SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift` @@ -633,6 +633,86 @@ - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` - 기대 결과: exit code 0으로 빌드가 완료된다. +### Phase 19: 스케줄 라우팅 및 라이브 상세 전역 표시 계층 보정 + +- [x] **Task 19.1: 스케줄 라우팅과 라이브 상세 표시 흐름 조사** + - 대상 파일: + - 확인: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelScheduleSection.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` + - 확인: `SodaLive/Sources/App/AppState.swift` + - 확인: `SodaLive/Sources/ContentView.swift` + - 비교: `SodaLive/Sources/V2/Main/MainView.swift` + - 비교: `docs/20260714_라이브룸_전역_표시와_키보드_레이아웃_수정/prd.md` + - 작업 내용: + - `.live`와 `.liveReplay`가 두 스케줄 handler에서 같은 라이브 분기로 처리되는지 확인한다. + - `.live`의 `targetId`는 room ID, `.liveReplay`와 `.audio`의 `targetId`는 콘텐츠 ID라는 타입 의미를 대조한다. + - `liveDetailSheet` 상태 갱신 지점과 `LiveDetailView` 렌더 위치를 분리해 추적한다. + - 메인 화면에서는 보이고 navigation destination에서는 가려지는 계층 차이를 기존 전역 라이브룸 표시 방식과 비교한다. + - 검증 기준: + - 실행 명령: `rg -n -A14 "func handle(Following)?ScheduleTap" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift SodaLive/Sources/V2/Main/MainView.swift` + - 기대 결과: `.liveReplay`가 `.live`와 묶여 잘못된 라이브 경로로 전달되는 라우팅 문제를 확인한다. + - 실행 명령: `rg -n "liveDetailSheet|LiveDetailView\\(|\\.overlay" SodaLive/Sources/App/AppState.swift SodaLive/Sources/ContentView.swift` + - 기대 결과: `.live` 상태 갱신은 정상이며 `LiveDetailView` 렌더 위치가 `NavigationStack` 루트 콘텐츠 내부인 표시 계층 문제를 확인한다. + +- [x] **Task 19.2: 스케줄 type별 라우팅 보정** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` + - 수정: `SodaLive/Sources/V2/Main/MainView.swift` + - 작업 내용: + - `CreatorChannelView.handleScheduleTap`에서 `.live`만 `showLiveDetail(targetId)`로 전달한다. + - `CreatorChannelView.handleScheduleTap`에서 `.liveReplay`와 `.audio`는 동일한 오디오 콘텐츠 상세 화면을 여는 `showContentDetail(targetId)`로 전달한다. + - `MainView.handleFollowingScheduleTap`에서 `.live`는 기존 `handleRecommendationLiveTap(roomId:)` 흐름을 유지한다. + - `MainView.handleFollowingScheduleTap`에서 `.liveReplay`와 `.audio`는 동일한 오디오 콘텐츠 상세 화면을 여는 `handleRecommendationContentTap(contentId:)`로 전달한다. + - 신규 route나 helper를 추가하지 않고 기존 상세 진입 함수를 재사용한다. + - 검증 기준: + - 실행 명령: `rg -n -A14 "func handle(Following)?ScheduleTap" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift SodaLive/Sources/V2/Main/MainView.swift` + - 기대 결과: 두 handler 모두 `.live`는 라이브 경로, `.liveReplay`와 `.audio`는 동일한 오디오 콘텐츠 상세 경로로 분리되어 있다. + - 실행 명령: `rg -n "case \\.live, \\.liveReplay" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift SodaLive/Sources/V2/Main/MainView.swift` + - 기대 결과: 검색 결과가 없다. + +- [x] **Task 19.3: 라이브 상세 렌더 호스트를 앱 전역 overlay로 이동** + - 대상 파일: + - 수정: `SodaLive/Sources/ContentView.swift` + - 작업 내용: + - `NavigationStack` 내부 루트 `ZStack`의 기존 `liveDetailSheet` 렌더 분기를 복제하지 않고 `NavigationStack.overlay`의 `ZStack` 첫 번째 계층으로 이동한다. + - 외부 overlay에서 동일 상태를 처리한 뒤 불필요해진 기존 위치의 렌더 분기는 제거한다. + - `roomId`, 네 개의 action callback, `onClickClose`의 `hideLiveDetailSheet()` 호출과 animation은 변경하지 않는다. + - 전역 라이브룸과 외부 이동 확인 다이얼로그는 라이브 상세 뒤에 배치해 기존 우선순위를 유지한다. + - 검증 기준: + - 실행 명령: `rg -n "NavigationStack|if let liveDetailSheet|LiveDetailView\\(|LiveRoomViewV2\\(|leaveLiveNavigationDialog|\\.overlay" SodaLive/Sources/ContentView.swift` + - 기대 결과: `if let liveDetailSheet` 상태 기반 렌더 분기는 `NavigationStack` 바깥 overlay에 하나만 존재하고 기존 루트 `ZStack`에는 남지 않으며, 표시 순서가 라이브 상세 → 라이브룸 → 외부 이동 확인 다이얼로그다. + +- [x] **Task 19.4: 정적 검사와 Debug 빌드 검증** + - 대상 파일: + - 확인: `SodaLive/Sources/ContentView.swift` + - 확인: `SodaLive/Sources/App/AppState.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` + - 확인: `SodaLive/Sources/V2/Main/MainView.swift` + - 작업 내용: + - 전역 `LiveDetailView` 호스트 수와 기존 `.liveDetail` 호출부를 검색한다. + - `.liveReplay`가 라이브 분기로 남아 있지 않은지 검색한다. + - whitespace 오류를 확인하고 공식 `SodaLive-dev` Debug 빌드를 실행한다. + - 검증 기준: + - 실행 명령: `git diff --check` + - 기대 결과: 출력 없이 exit code 0이다. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 기대 결과: `** BUILD SUCCEEDED **`. + +- [ ] **Task 19.5: 진입점별 수동 동작 검증** + - 대상 화면: + - 크리에이터 채널 홈의 `.live` 예약 스케줄 + - 크리에이터 채널 홈의 `.liveReplay` 스케줄 + - 메인 팔로잉 화면의 `.liveReplay` 스케줄 + - 크리에이터 채널 라이브 탭 + - 메인 화면의 기존 라이브 상세 진입점 + - 작업 내용: + - 크리에이터 채널의 `.live` 항목 tap 직후 현재 화면 위에 라이브 상세가 보이는지 확인한다. + - 크리에이터 채널과 메인의 `.liveReplay` 항목 tap 시 `.audio`와 동일한 오디오 콘텐츠 상세로 이동하고 라이브 상세나 라이브룸이 표시되지 않는지 확인한다. + - dim 영역 또는 닫기 버튼으로 상세를 닫았을 때 진입 전 화면과 navigation path가 유지되는지 확인한다. + - 라이브 상세에서 라이브룸에 입장할 수 있는 기존 경로는 라이브룸이 상세 위에 표시되는지 확인한다. + - 검증 기준: + - 기대 결과: `.live`만 라이브 상세를 표시하고 `.liveReplay`와 `.audio`는 동일한 오디오 콘텐츠 상세로 이동하며, 닫기와 라이브룸 표시 우선순위에 회귀가 없다. + ## 검증 기록 - 2026-07-01: `docs/20260701_크리에이터_채널_홈/prd.md`를 기준으로 API 선구현, Figma 섹션별 phase, placeholder tab, API 실패 placeholder, DM/화보 제외 범위를 계획에 반영했다. @@ -658,3 +738,50 @@ - 2026-07-02: 사용자 확인 사항을 반영해 HeaderView는 `CreatorChannelHomeResponse.creator` 데이터로 채우고, 별도 프로필 이미지 없이 큰 배경 이미지만 표시하며, title bar는 기본 투명 배경으로 header 위에 overlay되도록 Task 3.2/3.4/3.5와 최종 검증 기준을 갱신했다. - 2026-07-03: 사용자 요청에 따라 Task 17.2의 대화하기 액션을 캐릭터 상세 이동이 아니라 기존 Character Detail 하단 `대화하기`와 동일한 `TalkApi.createChatRoom` 호출 후 `.chatRoom(id:)` 직접 진입으로 변경했다. `CreatorChannelView`의 로그인/한국 본인인증/content settings guard는 유지했고, 본인인증 완료 후 `pendingAction`도 같은 `startChat(characterId:)` helper를 사용하도록 연결했다. `rg -n "characterDetail\\(characterId: characterId\\)|startChat\\(characterId:|createChatRoom\\(characterId:|chatRoom\\(id:" "SodaLive/Sources/V2/CreatorChannel"`와 ast-grep `AppState.shared.setAppStep(step: .characterDetail(characterId: $ID))` 검색으로 CreatorChannel 대화하기 경로에 캐릭터 상세 이동이 남지 않았음을 확인했다. 테스트는 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` 실행 결과 `Scheme SodaLive-dev is not currently configured for the test action.`으로 RED/GREEN 자동화가 불가했다. 빌드는 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`가 `BUILD SUCCEEDED`로 완료됐다. - 2026-07-03: Phase 18 최종 조립과 검증을 완료했다. 최신 코드 기준에 맞춰 대화하기 액션은 `CreatorChannelHeaderSection` 내부에 유지하고, `CreatorChannelHomeView` 홈 콘텐츠 순서는 현재 라이브, 최신 오디오, 채널 후원, 공지, 스케줄, 오디오, 시리즈, 커뮤니티, 팬Talk, 소개, 활동, SNS 순서로 확인했다. title bar 알림 설정 asset은 최신 코드 기준인 `ic_bar_bell_colored`를 유지했다. `rg -n "ZStack|overlay|Color\\.clear|backgroundProgress|CreatorChannelTitleBar|CreatorChannelHeaderSection|CreatorChannelTabBar|CreatorChannelHomeView|CreatorChannelTalkActionSection|CreatorChannelCurrentLiveSection|CreatorChannelLatestAudioSection|CreatorChannelDonationSection|CreatorChannelNoticeSection|CreatorChannelScheduleSection|CreatorChannelAudioSection|CreatorChannelSeriesSection|CreatorChannelCommunitySection|CreatorChannelFanTalkSection|CreatorChannelIntroduceSection|CreatorChannelActivitySection|CreatorChannelSnsSection" ...`와 `rg -n "화보|photo|ic_new_dm|DM|ic_new_talk|ic_sns_|ic_new_bar_back|ic_new_follow|ic_new_following|ic_new_more|ic_bar_bell|ic_bar_bell_colored|creatorFollow|follow: true, notify: true|follow: false, notify: false|follow: true, notify: false|onTapNotify|onTapUnnotify|ZStack|overlay|Color\\.clear|isApiFailedPlaceholderVisible|countryCode|auth == false" ...`로 성공 기준을 회귀 점검했고, 신규 CreatorChannel Swift 파일은 `project.pbxproj`의 Sources phase에 포함되어 있음을 확인했다. `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`는 `BUILD SUCCEEDED`로 완료됐다. +- 2026-07-20: 크리에이터 채널 홈 스케줄 item에서 `CreatorChannelScheduleSection` → `CreatorChannelView.handleScheduleTap` → `showLiveDetail` → `AppState.setAppStep(.liveDetail)`까지 이벤트와 상태 갱신이 정상임을 확인했다. `liveDetailSheet`의 렌더 호스트가 `NavigationStack` 루트 `ZStack` 안에 있어 destination인 `CreatorChannelView` 뒤에 가려지는 것이 근본 원인이다. 기존 `AppState` 상태나 채널 callback을 복제하지 않고 `SodaLive/Sources/ContentView.swift`에서 해당 렌더 분기만 기존 외부 overlay로 옮기는 최소 수정안을 계획했다. 구현과 빌드·수동 검증은 아직 수행하지 않았다. +- 2026-07-20: 사용자 확인에 따라 Task 19.3에 기존 `liveDetailSheet` 렌더 분기를 복제하지 않고 외부 overlay로 이동하며, 이동 후 기존 루트 `ZStack`의 분기를 제거하고 상태 기반 호스트가 하나만 남는지 검증하도록 명시했다. +- 2026-07-20: 사용자 확인에 따라 `LiveDetailView` 표시 대상을 `.live`로 한정하고, `.liveReplay`는 다시 듣기 콘텐츠의 `contentId`로 `.audio`와 동일한 오디오 콘텐츠 상세에 진입하도록 요구사항을 수정했다. 현재 `CreatorChannelView.handleScheduleTap`과 `MainView.handleFollowingScheduleTap`이 모두 `.liveReplay`를 `.live`와 묶고 있어 두 파일의 분기를 수정하는 Task 19.2를 추가하고 기존 후속 task를 19.3~19.5로 재번호화했다. +- 2026-07-20: Task 19.2~19.4 구현을 완료했다. `CreatorChannelView.handleScheduleTap`과 `MainView.handleFollowingScheduleTap`에서 `.live`만 라이브 경로로 두고 `.audio, .liveReplay`를 콘텐츠 상세 경로로 분리했다. `ContentView`의 `liveDetailSheet` 상태 기반 `LiveDetailView` 렌더 호스트는 기존 루트 `ZStack`에서 제거하고 `NavigationStack.overlay`의 첫 번째 계층으로 이동해 라이브 상세 → 전역 라이브룸 → 외부 이동 확인 다이얼로그 순서를 맞췄다. `rg -n -A14 "func handle(Following)?ScheduleTap|if let liveDetailSheet|LiveDetailView\\(|LiveRoomViewV2\\(|leaveLiveNavigationDialog|\\.overlay|case \\.live, \\.liveReplay" ...`로 `.liveReplay` 묶음 제거와 overlay 위치를 확인했고, `git diff --check`는 출력 없이 통과했다. `sourcekit-lsp`는 active 상태였으나 변경 파일별 `lsp_diagnostics`가 3초 내 fresh diagnostics를 반환하지 않았다. 공식 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`는 `** BUILD SUCCEEDED **`로 완료됐다. Task 19.5의 실제 진입점별 기기/시뮬레이터 수동 검증은 아직 수행하지 않았다. + +### Phase 20: 종료된 라이브 상세 DIM 잔류 보정 + +- [x] **Task 20.1: 종료된 라이브 상세 실패 흐름 문서화와 원인 확인** + - 대상 파일: + - 확인: `SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift` + - 확인: `SodaLive/Sources/Live/Room/Detail/LiveDetailViewModel.swift` + - 확인: `SodaLive/Sources/ContentView.swift` + - 작업 내용: + - `LiveDetailViewModel.getDetail` 실패 시 로컬 `isShowPopup`만 켜지고 `LiveDetailView` 표시 상태가 해제되지 않는 흐름을 확인한다. + - `LiveDetailView`가 닫히지 않으면 `Color.black.opacity(0.7)` DIM 배경이 계속 남는 것을 수정 대상으로 기록한다. + - 검증 기준: + - 실행 명령: `rg -n "getDetail\(roomId:|isShowPopup = true|hideView\(|onClickClose|liveDetailSheet" SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift SodaLive/Sources/Live/Room/Detail/LiveDetailViewModel.swift SodaLive/Sources/ContentView.swift` + - 기대 결과: 상세 조회 실패와 overlay 닫기 경로가 확인된다. + +- [x] **Task 20.2: 종료된 라이브 상세 실패 시 DIM 해제** + - 대상 파일: + - 수정: `SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift` + - 작업 내용: + - `LiveDetailView`가 room 데이터 없이 상세 조회 실패 토스트를 표시해야 하는 경우 전역 토스트로 메시지를 전달한다. + - 같은 실패 흐름에서 기존 `onClickClose`/`AppState.back()` 기반 close 경로를 호출해 `LiveDetailView`와 DIM overlay를 닫는다. + - 정상적으로 room 데이터를 받은 라이브 상세 표시와 기존 닫기 버튼 동작은 변경하지 않는다. + - 검증 기준: + - 실행 명령: `rg -n "handleDetailLoadFailure|AppState.shared.errorMessage|AppState.shared.isShowErrorPopup|hideView\(" SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift` + - 기대 결과: 상세 조회 실패 시 전역 토스트와 close 경로 호출이 확인된다. + +- [x] **Task 20.3: 정적 검사와 Debug 빌드 검증** + - 대상 파일: + - 확인: `SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift` + - 확인: `SodaLive/Sources/ContentView.swift` + - 작업 내용: + - 종료 라이브 실패 흐름과 기존 overlay 순서를 정적 검색으로 확인한다. + - `git diff --check`와 공식 Debug 빌드를 실행한다. + - 검증 기준: + - 실행 명령: `git diff --check` + - 기대 결과: 출력 없이 exit code 0이다. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 기대 결과: `** BUILD SUCCEEDED **`. + +- 2026-07-20: Phase 20 구현을 완료했다. `LiveDetailViewModel.getDetail` 실패 시 `viewModel.isShowPopup = true`만 설정되고 `LiveDetailView` 자체 표시 상태는 해제되지 않아 `Color.black.opacity(0.7)` DIM 배경이 남는 원인을 확인했다. `LiveDetailView`에서 `viewModel.isShowPopup` 변경을 감지하되 `viewModel.room == nil`인 상세 조회 실패에만 `handleDetailLoadFailure()`를 호출하도록 했고, 해당 helper는 기존 메시지를 `AppState.shared.errorMessage`/`isShowErrorPopup`으로 넘긴 뒤 `hideView()`로 기존 `onClickClose` 또는 `AppState.back()` 닫기 경로를 실행한다. `rg -n "handleDetailLoadFailure|AppState.shared.errorMessage|AppState.shared.isShowErrorPopup|viewModel.room == nil|hideView\\(|Phase 20|이미 종료된 라이브 상세 조회 실패" ...`와 `git diff --check`를 통과했다. `lsp_diagnostics`는 `Kingfisher` 모듈 해석 문제로 실패했으나, 공식 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`는 `** BUILD SUCCEEDED **`로 완료됐다. 실제 종료된 라이브 API 응답을 사용하는 기기/시뮬레이터 수동 검증은 수행하지 않았다. +- 2026-07-20: Phase 20 후속 보정을 적용했다. 종료된 라이브 상세 실패 처리에서 전역 토스트 상태를 먼저 켠 뒤 `LiveDetailView`를 닫으면 overlay 해제와 함께 토스트 표시가 누락될 수 있어, 실패 메시지를 지역 변수로 보존하고 `viewModel.isShowPopup = false` 및 `hideView()`로 DIM overlay를 먼저 해제한 다음 다음 main runloop에서 `AppState.shared.errorMessage`/`isShowErrorPopup`을 설정하도록 순서를 조정했다. +- 2026-07-20: Phase 20 토스트 계층 보정을 적용했다. `sodaToast`는 적용된 content 위에 overlay를 붙이는 modifier인데, 기존 전역 토스트 host가 `NavigationStack` 내부 루트 `ZStack`에 붙어 있어 navigation destination 및 전역 `LiveDetailView` overlay보다 아래 계층에 남을 수 있었다. `ContentView`의 전역 `.sodaToast(isPresented: $appState.isShowErrorPopup, ...)`를 `NavigationStack.overlay` 뒤의 최상위 modifier로 이동해 DIM overlay 해제 후에도 토스트가 최상위에서 표시되도록 했다. +- 2026-07-20: Phase 20 추가 보정을 적용했다. 전역 `AppState.isShowErrorPopup`이 이미 `true`인 상태에서 같은 값을 다시 설정하면 `SodaToastModifier.onChange(of: isPresented)`가 재실행되지 않을 수 있고, `LiveDetailView` close animation과 같은 runloop에서 토스트를 켜면 표시 타이밍이 겹칠 수 있어 종료 상세 실패 처리에서 `AppState.shared.isShowErrorPopup = false`로 먼저 reset한 뒤 `hideView()`를 호출하고, 0.25초 후 `AppState.shared.errorMessage`/`isShowErrorPopup`을 설정하도록 조정했다. `lsp_diagnostics`는 기존 SourceKit 환경의 `No such module 'Kingfisher'`로 실패했으나, `git diff --check`는 통과했고 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`는 `** BUILD SUCCEEDED **`로 완료됐다. diff --git a/docs/20260701_크리에이터_채널_홈/prd.md b/docs/20260701_크리에이터_채널_홈/prd.md index 35f9b2ae..08066f78 100644 --- a/docs/20260701_크리에이터_채널_홈/prd.md +++ b/docs/20260701_크리에이터_채널_홈/prd.md @@ -35,6 +35,9 @@ Figma 전체 화면 기준 공통 shell은 아래를 따른다. - tab-bar는 title bar와 붙는 순간 sticky 상태가 되어야 하고, sticky 이후에는 아래 콘텐츠만 스크롤되어야 한다. - `isAiChatAvailable` 값에 따라 대화하기 버튼 노출이 달라지지만, 버튼이 없어도 액션 영역 높이는 유지해야 한다. - 선택된 tab 및 홈 섹션의 `전체보기` 진입 화면은 전체 기능 구현 전에도 placeholder destination이 필요하다. +- 후속 버그(2026-07-20): `AppState.liveDetailSheet`는 전역 상태지만 `LiveDetailView`가 `NavigationStack`의 루트 콘텐츠 안에서 렌더링된다. 크리에이터 채널은 navigation destination으로 루트 콘텐츠 위에 표시되므로, 채널 스케줄의 라이브 예약 항목을 눌러도 상세가 현재 화면 위에 나타나지 않고 메인 화면으로 돌아간 뒤에야 보인다. +- 후속 버그(2026-07-20): `.liveReplay`는 다시 듣기 카테고리의 업로드 콘텐츠이고 `targetId`도 콘텐츠 ID이지만, 현재 크리에이터 채널과 메인 스케줄 분기에서는 `.live`와 함께 처리되어 라이브 진입 경로로 잘못 전달된다. `.liveReplay`의 상세 화면은 `.audio`와 동일한 오디오 콘텐츠 상세 화면이다. +- 후속 버그(2026-07-20): `LiveDetailView`가 이미 종료된 라이브의 상세 조회 실패 응답을 받으면 `이미 종료된 라이브 입니다.` 토스트는 표시되지만 `LiveDetailView` overlay가 닫히지 않아 DIM 배경이 화면에 남는다. ## 3. Goals - `creatorId`로 `GET /api/v2/creator-channels/{creatorId}/home`을 호출하고 응답 데이터로 크리에이터 채널 공통 shell과 홈 탭 콘텐츠를 구성한다. @@ -51,6 +54,9 @@ Figma 전체 화면 기준 공통 shell은 아래를 따른다. - 홈 tab 외 tab-bar 항목 및 홈 섹션의 전체보기 destination은 placeholder 형태로 먼저 연결한다. - V2 하위의 재사용 가능한 공용 컴포넌트는 우선 재사용한다. - 빈 섹션은 section title과 empty state를 표시하지 않고 숨긴다. +- 크리에이터 채널의 라이브 예약 스케줄 항목을 누르면 현재 채널 화면 위에 기존 `LiveDetailView`가 즉시 표시되고, 닫으면 같은 채널 화면으로 돌아온다. +- `.liveReplay` 스케줄 항목을 누르면 `targetId`를 콘텐츠 ID로 사용해 `.audio`와 동일한 기존 오디오 콘텐츠 상세 페이지로 이동한다. +- 이미 종료된 라이브의 `LiveDetailView`를 열면 `이미 종료된 라이브 입니다.` 토스트를 표시하고 `LiveDetailView`/DIM overlay를 닫아 진입 전 화면을 유지한다. ## 4. Non-Goals - `화보` 탭과 화보 섹션은 구현하지 않는다. @@ -58,6 +64,8 @@ Figma 전체 화면 기준 공통 shell은 아래를 따른다. - 후원 작성, 댓글/좋아요, 커뮤니티 구매, 콘텐츠 구매의 실제 mutation 구현은 이번 PRD의 필수 범위로 보지 않는다. - DM 버튼 및 DM 보내기 진입은 추후 작업으로 미루고 이번 범위에서 구현하지 않는다. - 라이브룸, 오디오 상세, 시리즈 상세, 커뮤니티 상세, 팬Talk 전체, 후원 전체의 실제 상세 기능을 새로 구현하지 않는다. 기존 진입 흐름이 있으면 연결하고, 없으면 placeholder로 둔다. +- `LiveDetailView`, `AppStep.liveDetail`, `LiveDetailSheetState`의 API나 상세 내부 동작은 변경하지 않는다. +- 크리에이터 채널 전용 라이브 상세 상태나 중복 `LiveDetailView` 호스트를 추가하지 않는다. - Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다. - `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다. @@ -254,7 +262,13 @@ Figma 참조: - `schedules`를 사용해 예정 항목을 표시한다. - 각 item은 `scheduledAtUtc`, `title`, `type`, `targetId`를 사용한다. - `type`은 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY` 등 기존 `CreatorActivityType` 처리 범위를 따른다. -- schedule tap 시 기존 `MainView.handleFollowingScheduleTap(type:targetId:)`와 같은 분기 패턴을 검토해 재사용한다. +- `.live`의 `targetId`는 라이브 room ID이며, 크리에이터 채널에서는 기존 `AppState.setAppStep(step: .liveDetail(...))` 흐름으로 전달한다. +- `.liveReplay`는 다시 듣기 카테고리의 업로드 콘텐츠이므로 `targetId`를 콘텐츠 ID로 사용해 `.audio`와 동일한 오디오 콘텐츠 상세 화면인 `AppStep.contentDetail(contentId:)`로 이동한다. +- `.audio`도 `targetId`를 콘텐츠 ID로 사용해 `AppStep.contentDetail(contentId:)`로 이동한다. +- `MainView.handleFollowingScheduleTap(type:targetId:)`와 `CreatorChannelView.handleScheduleTap(type:targetId:)` 모두 `.live`와 `.liveReplay`를 같은 case로 묶지 않고 동일한 타입 의미를 적용한다. +- 전역 `liveDetailSheet`의 렌더 호스트는 `NavigationStack`의 destination보다 위인 `ContentView` 외부 overlay에 둔다. +- 기존 `NavigationStack` 루트 `ZStack` 안의 `liveDetailSheet` 렌더 분기는 복제하지 않고 외부 overlay로 이동하며, 이동 후 기존 위치의 분기는 제거한다. +- overlay 내부 표시 순서는 라이브 상세, 전역 라이브룸, 라이브룸 외부 이동 확인 다이얼로그 순으로 두어 라이브룸과 확인 다이얼로그의 기존 우선순위를 유지한다. - 지원하지 않는 type은 crash 없이 무시하거나 placeholder로 둔다. ### 7.12 오디오 섹션 @@ -363,6 +377,7 @@ Figma 참조: - 프로젝트 설정 변경은 신규 파일이 Xcode project에 자동 포함되지 않는 구조일 때만 수행한다. - Kotlin `Long`으로 정의된 id/count 계열 값은 Swift에서 `Int`로 통일한다. - SNS 섹션 아이콘은 Figma localhost asset이 아니라 프로젝트 asset catalog의 `ic_sns_*` 아이콘을 사용한다. +- 라이브 상세 표시 계층 보정은 기존 전역 상태를 재사용하고 `SodaLive/Sources/ContentView.swift` 한 파일의 렌더 위치만 변경한다. ## 10. Success Criteria - 크리에이터 채널 진입 시 `CreatorChannelView`가 표시되고, 기본 선택 탭은 `홈`이다. @@ -393,6 +408,13 @@ Figma 참조: - 홈 외 tab-bar 항목 선택 시 선택한 탭 제목을 표시하는 placeholder page가 표시된다. - 홈 섹션의 전체보기 action은 기존 화면 또는 placeholder destination으로 이동한다. - 기존 V2 공용 컴포넌트를 재사용 가능한 곳에서 재사용하고, 채널 홈 전용 UI만 전용 `Components`로 분리한다. +- 크리에이터 채널 홈 스케줄의 `.live` 항목을 누르면 메인 화면으로 이동하지 않아도 라이브 상세가 현재 채널 위에 즉시 표시된다. +- 크리에이터 채널과 메인 스케줄의 `.liveReplay` 항목을 누르면 라이브 상세나 라이브룸이 아니라 `.audio`와 동일한 오디오 콘텐츠 상세 페이지로 이동한다. +- `.liveReplay` 처리 중에는 `AppState.liveDetailSheet`를 설정하거나 라이브 입장 함수를 호출하지 않는다. +- 라이브 상세를 닫으면 navigation path를 변경하지 않고 기존 크리에이터 채널 화면이 유지된다. +- 메인 화면과 다른 navigation destination에서 사용하는 기존 `.liveDetail` 진입도 동일한 전역 호스트에서 한 번만 표시된다. +- `ContentView`의 `if let liveDetailSheet` 상태 기반 렌더 분기는 외부 overlay에 하나만 남고 기존 루트 `ZStack`에는 남지 않는다. +- 이미 종료된 라이브 상세 조회 실패 시 `AppState.liveDetailSheet` 또는 각 화면의 `LiveDetailView` 표시 상태가 해제되어 DIM 배경이 남지 않는다. ## 11. Open Questions 해당 없음. @@ -411,3 +433,6 @@ Figma 참조: - 2026-07-02: 사용자 확인 사항과 최신 코드 기준을 반영해 title bar Figma URL 3종과 사용할 아이콘 asset(`ic_new_bar_back`, `ic_new_follow`, `ic_new_following`, `ic_new_more`, `ic_bar_bell`, `ic_bar_bell_colored`)을 확정 요구사항으로 추가했다. - 2026-07-02: 기존 `UserProfile` 동작을 확인해 title bar의 팔로우/팔로우 취소/알림 설정/알림 설정 취소를 `UserRepository.creatorFollow(creatorId:follow:notify:)`로 처리하도록 확정했다. - 2026-07-02: 사용자 확인 사항을 반영해 HeaderView는 `CreatorChannelHomeResponse.creator` 데이터로 채우고, 별도 프로필 이미지 없이 큰 배경 이미지만 표시하며, title bar는 기본 투명 배경으로 header 위에 overlay되도록 요구사항을 정리했다. +- 2026-07-20: 크리에이터 채널 스케줄의 라이브 예약 항목 tap은 `CreatorChannelView.handleScheduleTap`에서 `AppState.setAppStep(.liveDetail)`까지 정상 전달되지만, `LiveDetailView`가 `NavigationStack` 루트 콘텐츠 내부에 있어 destination인 `CreatorChannelView` 뒤에 렌더링되는 표시 계층 버그를 확인했다. 기존 전역 상태와 callback은 유지하고 `ContentView` 외부 overlay로 렌더 호스트만 이동하는 최소 수정안을 확정했다. +- 2026-07-20: 사용자 확인에 따라 기존 루트 `ZStack`의 `liveDetailSheet` 분기를 외부 overlay에 복제하지 않고 이동하며, 이동 후 불필요해진 기존 위치의 분기를 제거하는 것으로 범위를 명확히 했다. +- 2026-07-20: 사용자 확인에 따라 `LiveDetailView` 표시 대상은 `.live`로 한정했다. `.liveReplay`는 다시 듣기 카테고리의 업로드 콘텐츠이므로 `.audio`와 동일한 오디오 콘텐츠 상세 화면으로 이동하며, 현재 두 스케줄 handler에서 `.live`와 묶인 분기를 각각 수정 대상으로 추가했다.