From 4b209dcd7247e0a7e462f22f9109f6f6850a2674 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Fri, 14 Aug 2026 16:41:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=EC=B0=A8=EB=8B=A8?= =?UTF-8?q?=C2=B7=EC=8B=A0=EA=B3=A0=20=EB=A9=94=EB=89=B4=EB=A5=BC=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannel/CreatorChannelView.swift | 58 ++++++++++++++- .../CreatorChannelViewModel.swift | 70 +++++++++++++++++++ docs/20260701_크리에이터_채널_홈/plan-task.md | 55 +++++++++++++++ docs/20260701_크리에이터_채널_홈/prd.md | 15 ++++ 4 files changed, 197 insertions(+), 1 deletion(-) diff --git a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift index f5e374e2..a06e93e6 100644 --- a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift @@ -19,6 +19,10 @@ struct CreatorChannelView: View { @State private var isShowChannelDonationDialog = false @State private var isShowAuthView = false @State private var isShowAuthConfirmView = false + @State private var isShowReportMenu = false + @State private var isShowUserBlockConfirm = false + @State private var isShowUserReport = false + @State private var isShowProfileReport = false @State private var isCreatorActionMenuPresented = false @State private var pendingAction: (() -> Void)? = nil @State private var payload = Payload() @@ -183,6 +187,56 @@ struct CreatorChannelView: View { ) } + if viewModel.response != nil && isShowReportMenu { + VStack(spacing: 0) { + ProfileReportMenuView( + isShowing: $isShowReportMenu, + isBlockedUser: false, + userBlockAction: { isShowUserBlockConfirm = true }, + userUnBlockAction: {}, + userReportAction: { isShowUserReport = true }, + profileReportAction: { isShowProfileReport = true } + ) + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(Color(hex: "222222")) + .frame(width: proxy.size.width, height: 15.3) + } + } + .ignoresSafeArea() + } + + if let creator = viewModel.response?.creator, isShowUserBlockConfirm { + UserBlockConfirmDialogView( + isShowing: $isShowUserBlockConfirm, + nickname: creator.nickname, + confirmAction: { + viewModel.userBlock(userId: creator.creatorId) { + dismiss() + } + } + ) + } + + if isShowUserReport { + UserReportDialogView( + isShowing: $isShowUserReport, + confirmAction: { reason in + viewModel.report(type: .USER, userId: creatorId, reason: reason) + } + ) + } + + if isShowProfileReport { + ProfileReportDialogView( + isShowing: $isShowProfileReport, + confirmAction: { + viewModel.report(type: .PROFILE, userId: creatorId) + } + ) + } + communityOverlay(proxy: proxy) } } @@ -227,7 +281,9 @@ struct CreatorChannelView: View { onTapUnnotify: { viewModel.creatorFollow(follow: true, notify: false) }, - onTapMore: {} + onTapMore: { + isShowReportMenu = true + } ) } diff --git a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift index 555a9cd9..3a537e13 100644 --- a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift +++ b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift @@ -4,6 +4,7 @@ import Combine final class CreatorChannelViewModel: ObservableObject { private let repository = CreatorChannelHomeRepository() private let userRepository = UserRepository() + private let reportRepository = ReportRepository() private let communityRepository = CreatorCommunityRepository() private var subscription = Set() @@ -156,6 +157,75 @@ final class CreatorChannelViewModel: ObservableObject { .store(in: &subscription) } + func userBlock(userId: Int, onSuccess: @escaping () -> Void) { + isLoading = true + + userRepository.memberBlock(userId: userId) + .sink { [weak self] result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + self?.isLoading = false + } + } receiveValue: { [weak self] response in + guard let self else { return } + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: response.data) + + if decoded.success { + onSuccess() + } else { + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + + self.isLoading = false + } + .store(in: &subscription) + } + + func report(type: ReportType, userId: Int? = nil, reason: String = I18n.Dialog.MemberProfile.reportProfile) { + isLoading = true + + let request = ReportRequest(type: type, reason: reason, reportedMemberId: userId) + reportRepository.report(request: request) + .sink { [weak self] result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + self?.isLoading = false + } + } receiveValue: { [weak self] response in + guard let self else { return } + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: response.data) + + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } catch { + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + + self.isLoading = false + } + .store(in: &subscription) + } + private func applyApiFailedPlaceholderState() { response = nil isApiFailedPlaceholderVisible = true diff --git a/docs/20260701_크리에이터_채널_홈/plan-task.md b/docs/20260701_크리에이터_채널_홈/plan-task.md index 3849b51a..45056c81 100644 --- a/docs/20260701_크리에이터_채널_홈/plan-task.md +++ b/docs/20260701_크리에이터_채널_홈/plan-task.md @@ -850,3 +850,58 @@ - 기대 결과: `** BUILD SUCCEEDED **`. - 2026-08-03: Phase 22 구현을 완료했다. RED 확인으로 `rg -n "CreatorChannelHeaderSection\(|onTapFollowerCount|followerList\(userId: creator\.creatorId\)|followerCount\(creator\.followerCount\.comma\(\)\)" "SodaLive/Sources/V2/CreatorChannel"`를 먼저 실행해 헤더 생성과 팔로워 수 표시만 있고 `onTapFollowerCount` 및 `.followerList(userId: creator.creatorId)` 연결이 없음을 확인했다. `CreatorChannelHeaderSection`에는 `onTapFollowerCount` closure와 팔로워 수 텍스트 tap gesture만 추가했고, `CreatorChannelView`에서는 내 채널일 때만 `AppState.shared.setAppStep(step: .followerList(userId: creator.creatorId))`를 호출하도록 연결했다. GREEN 확인으로 `rg -n "onTapFollowerCount|followerList\(userId: creator\.creatorId\)|FollowerListView\(userId:" "SodaLive/Sources/V2/CreatorChannel" "SodaLive/Sources/ContentView.swift"`가 헤더 콜백, 내 채널 라우팅, 기존 `FollowerListView` destination을 모두 찾았다. `git diff --check`는 출력 없이 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`는 `** BUILD SUCCEEDED **`로 완료됐다. 프로젝트에 테스트 번들 타깃이 없어 XCTest는 추가/실행하지 못했고, 로그인된 내 채널 계정이 필요한 실제 기기 탭 수동 검증은 수행하지 않았다. + +### Phase 23: title bar 사용자 차단/신고 메뉴 연결 + +- [x] **Task 23.1: 기존 프로필 신고 메뉴와 액션 흐름 연결** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift` + - 확인: `SodaLive/Sources/Report/ProfileReportMenuView.swift` + - 확인: `SodaLive/Sources/Explorer/Profile/UserProfileView.swift` + - 확인: `SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift` + - 작업 내용: + - `CreatorChannelView.titleBar`의 빈 `onTapMore`를 메뉴 표시 상태와 연결한다. + - 다른 크리에이터 채널에서만 기존 `ProfileReportMenuView`를 최상위 `ZStack` overlay에 표시한다. + - 메뉴는 `사용자 차단하기`, `사용자 신고하기`, `프로필 신고하기`를 제공하고 `.sheet`를 사용하지 않는다. + - 사용자 차단은 `UserBlockConfirmDialogView`, 사용자 신고는 `UserReportDialogView`, 프로필 신고는 `ProfileReportDialogView`를 재사용한다. + - 차단과 신고 요청은 기존 `UserProfileViewModel.userBlock(userId:)` 및 `report(type:userId:reason:)`의 Repository 흐름을 `CreatorChannelViewModel`에 필요한 만큼만 적용한다. + - 사용자 차단 API 성공 callback에서 `CreatorChannelView`의 기존 `dismiss()`를 호출해 진입 이전 화면으로 돌아간다. + - `CreatorChannelHomeResponse`에 차단 상태를 추가하지 않고, `ProfileReportMenuView`는 이번 요구사항의 고정 `사용자 차단하기` 항목으로 사용한다. + - 차단 성공 후 `AppState` navigation path를 초기화하거나 `.main` route를 강제로 설정하지 않는다. + - 새 BottomSheet View, 새 route, 새 API를 만들지 않는다. + - 검증 기준: + - 실행 명령: `rg -n "onTapMore|ProfileReportMenuView|UserBlockConfirmDialogView|UserReportDialogView|ProfileReportDialogView|userBlock\(userId:|report\(type:" SodaLive/Sources/V2/CreatorChannel SodaLive/Sources/Report/ProfileReportMenuView.swift` + - 기대 결과: 빈 `onTapMore`가 제거되고 기존 메뉴/다이얼로그 및 차단/신고 요청 연결과 차단 성공 시 `dismiss()` 호출이 확인된다. `CreatorChannelHomeResponse`에는 차단 상태가 추가되지 않는다. + - 수동 확인: 다른 크리에이터 채널에서 더보기 버튼을 누르면 하단 메뉴가 표시되고 DIM 탭으로 닫히며, 내 채널에서는 더보기 메뉴가 표시되지 않는다. 사용자 차단 성공 시 현재 채널이 닫히고 진입 이전 화면으로 돌아간다. + +- [x] **Task 23.2: overlay 계약과 Debug 빌드 검증** + - 대상 파일: + - 확인: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift` + - 작업 내용: + - 변경 전 RED로 `onTapMore`가 빈 closure이고 메뉴 연결이 없음을 확인한다. + - 변경 후 메뉴가 `.sheet`가 아닌 `CreatorChannelView`의 overlay 계층에 포함되는지 확인한다. + - whitespace 오류를 확인하고 공식 Debug 빌드를 실행한다. + - 검증 기준: + - 실행 명령: `git diff --check` + - 기대 결과: 출력 없이 exit code 0이다. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 기대 결과: `** BUILD SUCCEEDED **`. + - 사용자 수동 UI 검토: + - [x] 다른 크리에이터 채널의 title bar에 더보기 버튼이 표시된다. + - [x] 더보기 버튼을 누르면 화면 하단에 `사용자 차단하기`, `사용자 신고하기`, `프로필 신고하기`가 표시된다. + - [x] 메뉴 바깥 DIM 영역을 누르면 메뉴가 닫힌다. + - [x] `사용자 차단하기`를 누르면 대상 크리에이터 닉네임이 포함된 차단 확인창이 표시된다. + - [x] 차단 확인창에서 취소하면 현재 크리에이터 채널에 머문다. + - [x] 차단에 성공하면 현재 크리에이터 채널만 닫히고 직전 화면으로 돌아간다. 직전 화면이 다른 크리에이터 채널이어도 허용한다. + - [x] `사용자 신고하기`를 누르면 신고 사유를 선택할 수 있는 사용자 신고창이 표시된다. + - [x] `프로필 신고하기`를 누르면 프로필 신고 확인창이 표시된다. + - [x] 내 크리에이터 채널에서는 title bar 더보기 버튼과 차단/신고 메뉴가 표시되지 않는다. + +- 2026-08-14: `UserProfileView`의 기존 `ProfileReportMenuView` overlay와 차단/신고 다이얼로그 연결을 확인했다. `CreatorChannelView.titleBar`의 `onTapMore: {}` 누락을 같은 흐름에 연결하는 후속 Phase 23을 추가했으며, 새 문서나 새 BottomSheet View를 만들지 않는 것으로 범위를 정했다. 코드 구현과 빌드 검증은 아직 수행하지 않았다. +- 2026-08-14: 사용자 제안에 따라 차단 성공 시 현재 `CreatorChannelView`의 기존 `dismiss()`로 진입 이전 화면에 복귀하도록 Task 23.1을 보강했다. 화면을 즉시 닫으므로 `CreatorChannelHomeResponse` 차단 상태와 차단 해제 메뉴는 추가하지 않으며, navigation path 초기화나 강제 홈 route도 제외한다. +- 2026-08-14: 사용자 요청에 따라 UI 검증은 자동 캡처·자동 시각 리뷰 대신 Task 23.2의 사용자 수동 UI 검토 체크리스트로 수행한다. 체크리스트 확인 전까지 Phase 23 UI 검증은 완료로 표시하지 않는다. +- 2026-08-14: Task 23.1 구현과 독립 코드 리뷰를 완료했다. `CreatorChannelView`의 빈 `onTapMore`를 기존 `ProfileReportMenuView`와 세 신고/차단 다이얼로그에 연결했고, `CreatorChannelViewModel`에는 기존 Repository를 사용하는 차단/신고 요청만 추가했다. 차단 성공 callback은 현재 채널의 `dismiss()`만 호출한다. GREEN source contract와 `git diff --check`가 exit 0이었고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`에서 `** BUILD SUCCEEDED **`를 확인했다. Task 23.2는 사용자 수동 UI 검토 9개 항목이 남아 있어 미완료로 유지한다. +- 2026-08-14: 사용자가 Task 23.2의 수동 UI 검토 9개 항목을 모두 확인했다. 자동 검증과 사용자 수동 검토가 모두 완료되어 Phase 23을 완료 처리했다. diff --git a/docs/20260701_크리에이터_채널_홈/prd.md b/docs/20260701_크리에이터_채널_홈/prd.md index f98161c7..662ed6c1 100644 --- a/docs/20260701_크리에이터_채널_홈/prd.md +++ b/docs/20260701_크리에이터_채널_홈/prd.md @@ -40,6 +40,7 @@ Figma 전체 화면 기준 공통 shell은 아래를 따른다. - 후속 버그(2026-07-20): `LiveDetailView`가 이미 종료된 라이브의 상세 조회 실패 응답을 받으면 `이미 종료된 라이브 입니다.` 토스트는 표시되지만 `LiveDetailView` overlay가 닫히지 않아 DIM 배경이 화면에 남는다. - 후속 UI 보정(2026-07-20): 채널 홈 후원 섹션의 카드 너비가 `374pt`로 고정되어 있어 디바이스 너비가 `402pt`보다 작은 화면에서는 기준 디자인과 같은 비율을 유지하지 못한다. - 후속 연결(2026-08-03): 내 채널의 헤더 팔로워 수(`팔로워 n명`)는 표시만 되고 있어, 사용자가 자신을 팔로우하는 유저 목록으로 바로 이동할 수 없다. +- 후속 누락(2026-08-14): 다른 크리에이터 채널의 title bar 더보기 버튼은 표시되지만 `CreatorChannelView.titleBar`의 `onTapMore`가 빈 closure라 사용자 차단/신고 메뉴가 열리지 않는다. ## 3. Goals - `creatorId`로 `GET /api/v2/creator-channels/{creatorId}/home`을 호출하고 응답 데이터로 크리에이터 채널 공통 shell과 홈 탭 콘텐츠를 구성한다. @@ -61,6 +62,10 @@ Figma 전체 화면 기준 공통 shell은 아래를 따른다. - 이미 종료된 라이브의 `LiveDetailView`를 열면 `이미 종료된 라이브 입니다.` 토스트를 표시하고 `LiveDetailView`/DIM overlay를 닫아 진입 전 화면을 유지한다. - 채널 홈 후원 섹션 카드 너비는 디바이스 너비가 `402pt` 이상이면 `374pt`를 유지하고, `402pt` 미만이면 디바이스 너비에 비례해 축소한다. - 내 채널에서 헤더의 팔로워 수를 누르면 신규 V2 화면을 만들지 않고 기존 `AppStep.followerList(userId:)` 레거시 화면으로 이동한다. +- 다른 크리에이터 채널의 title bar 더보기 버튼을 누르면 `사용자 차단하기`, `사용자 신고하기`, `프로필 신고하기` 메뉴를 표시한다. +- 더보기 메뉴는 `.sheet`를 사용하지 않고 현재 화면의 최상위 `ZStack` overlay에 BottomSheet 형태로 표시한다. +- 새 메뉴 View를 만들지 않고 기존 `ProfileReportMenuView`와 `UserProfileView`의 차단/신고 확인 흐름을 재사용한다. +- 사용자 차단이 성공하면 현재 `CreatorChannelView`를 닫고 진입 이전 화면으로 돌아간다. ## 4. Non-Goals - `화보` 탭과 화보 섹션은 구현하지 않는다. @@ -72,6 +77,8 @@ Figma 전체 화면 기준 공통 shell은 아래를 따른다. - 크리에이터 채널 전용 라이브 상세 상태나 중복 `LiveDetailView` 호스트를 추가하지 않는다. - Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다. - `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다. +- `CreatorChannelHomeResponse`에 차단 상태를 추가하거나 차단 해제 메뉴를 구현하지 않는다. +- 차단 성공 후 navigation path를 초기화하거나 강제로 홈 route를 설정하지 않는다. ## 5. Target Users - 크리에이터 채널에서 라이브, 콘텐츠, 커뮤니티, 후원, 팬Talk 정보를 한 번에 확인하려는 사용자 @@ -427,6 +434,12 @@ Figma 참조: - 후원 카드의 높이, 내부 콘텐츠, 패딩, 카드 간격 및 후원 탭 세로 목록 레이아웃은 기존 상태를 유지한다. - 내 채널의 헤더 팔로워 수를 누르면 `FollowerListView(userId: creatorId)`로 이동한다. - 다른 사람 채널에서는 헤더 팔로워 수 탭으로 팔로워 목록에 이동하지 않는다. +- 다른 크리에이터 채널의 title bar 더보기 버튼을 누르면 DIM 배경과 함께 화면 하단에 `사용자 차단하기`, `사용자 신고하기`, `프로필 신고하기` 메뉴가 표시된다. +- 더보기 메뉴는 `.sheet`가 아니라 `CreatorChannelView`의 overlay 계층에서 표시되고, DIM 영역을 누르면 닫힌다. +- `사용자 차단하기`는 기존 `UserBlockConfirmDialogView`, `사용자 신고하기`는 `UserReportDialogView`, `프로필 신고하기`는 `ProfileReportDialogView` 흐름으로 연결된다. +- 내 채널에서는 기존처럼 title bar의 더보기 메뉴를 표시하지 않는다. +- 사용자 차단 API가 성공하면 기존 `dismiss()` 흐름으로 현재 크리에이터 채널을 닫고, 해당 채널을 열기 전 화면으로 돌아간다. +- 차단 성공 후 현재 채널에 머물지 않으므로 `CreatorChannelHomeResponse`에는 `isBlock`과 같은 차단 상태 필드를 추가하지 않는다. ## 11. Open Questions 해당 없음. @@ -449,3 +462,5 @@ Figma 참조: - 2026-07-20: 사용자 확인에 따라 기존 루트 `ZStack`의 `liveDetailSheet` 분기를 외부 overlay에 복제하지 않고 이동하며, 이동 후 불필요해진 기존 위치의 분기를 제거하는 것으로 범위를 명확히 했다. - 2026-07-20: 사용자 확인에 따라 `LiveDetailView` 표시 대상은 `.live`로 한정했다. `.liveReplay`는 다시 듣기 카테고리의 업로드 콘텐츠이므로 `.audio`와 동일한 오디오 콘텐츠 상세 화면으로 이동하며, 현재 두 스케줄 handler에서 `.live`와 묶인 분기를 각각 수정 대상으로 추가했다. - 2026-08-03: 사용자 요청에 따라 내 채널의 헤더 팔로워 수를 기존 레거시 `FollowerListView`로 연결하는 범위를 추가했다. 요청 문구의 `팔로잉 n명`은 현재 UI와 API 계약상 `팔로워 n명`, 즉 해당 채널을 팔로우하는 사용자 목록으로 해석한다. +- 2026-08-14: `UserProfileView`가 `ProfileReportMenuView`를 최상위 `ZStack` overlay로 표시하며 `UserBlockConfirmDialogView`, `UserReportDialogView`, `ProfileReportDialogView`에 연결하는 기존 구현을 확인했다. `CreatorChannelView.titleBar`의 빈 `onTapMore`를 같은 흐름에 연결하고 새 BottomSheet View나 `.sheet`를 추가하지 않는 최소 변경으로 요구사항을 확정했다. +- 2026-08-14: 사용자 제안에 따라 차단 성공 후 현재 `CreatorChannelView`를 `dismiss()`해 진입 이전 화면으로 돌아가도록 확정했다. 이 동작에서는 차단 후 메뉴를 다시 표시할 필요가 없으므로 `CreatorChannelHomeResponse`에 차단 상태를 추가하거나 강제로 홈 route를 설정하지 않는다.