From c441fb0b02480c0221b8f5a7bcbdbc97192d746f Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 29 Jun 2026 13:22:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=ED=98=84=EC=9E=AC=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=EC=84=B9=EC=85=98=EC=9D=84=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive.xcodeproj/project.pbxproj | 16 ++++ .../Components/MainHomeLiveSection.swift | 83 +++++++++++++++++++ .../MainHomeRecommendationView.swift | 10 +++ .../MainHomeRecommendationResponse.swift | 66 +++++++-------- SodaLive/Sources/V2/Main/MainView.swift | 8 +- .../plan-task.md | 42 +++++++++- docs/20260602_메인_홈_추천_UI_API_연동/prd.md | 1 + docs/agent-guides/code-style.md | 3 + 8 files changed, 191 insertions(+), 38 deletions(-) create mode 100644 SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeLiveSection.swift diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index aa002f3d..56df336b 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -213,6 +213,9 @@ 401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */; }; 401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */; }; 401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */; }; + 401A3FC22FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; }; + 401A3FC32FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; }; + 401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; }; 40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; }; 40235DEE2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; }; 40235DF02E37983D00B09A87 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEF2E37983D00B09A87 /* PersistenceController.swift */; }; @@ -1726,6 +1729,7 @@ 401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRecommendationView.swift; sourceTree = ""; }; 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingView.swift; sourceTree = ""; }; 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingView.swift; sourceTree = ""; }; + 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeLiveSection.swift; sourceTree = ""; }; 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecentContent+CoreDataClass.swift"; sourceTree = ""; }; 40235DEF2E37983D00B09A87 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; 40235DF22E37987300B09A87 /* RecentContentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentContentService.swift; sourceTree = ""; }; @@ -2694,9 +2698,18 @@ path = Repository; sourceTree = ""; }; + 401A3FC02FEEB00100A5BBA3 /* Components */ = { + isa = PBXGroup; + children = ( + 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */, + ); + path = Components; + sourceTree = ""; + }; 401A3F9D2FEEAFA000A5BBA3 /* Recommendation */ = { isa = PBXGroup; children = ( + 401A3FC02FEEB00100A5BBA3 /* Components */, 401A3F992FEEAFA000A5BBA3 /* Models */, 401A3F9C2FEEAFA000A5BBA3 /* Repository */, 401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */, @@ -5314,6 +5327,7 @@ 401A3FBA2FEEAFA000A5BBA3 /* MainHomeRecommendationViewModel.swift in Sources */, 401A3FA82FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FA92FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, + 401A3FC22FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */, 401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, 409143302A85652F00EEBFB1 /* CanChargeView.swift in Sources */, @@ -6060,6 +6074,7 @@ BA2B76FDCAF81D15E2D4E258 /* MainHomeRecommendationViewModel.swift in Sources */, 401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, + 401A3FC32FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */, 401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */, @@ -6833,6 +6848,7 @@ A4EB669DFFFB64681E045EF3 /* MainHomeRecommendationViewModel.swift in Sources */, 401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, + 401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */, 401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, ACD71056E97E13E48941ABBB /* MainPlaceholderTabView.swift in Sources */, diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeLiveSection.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeLiveSection.swift new file mode 100644 index 00000000..2e3d23d4 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeLiveSection.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct MainHomeLiveSection: View { + let items: [HomeLiveItem] + let onTapLive: (Int) -> Void + + var body: some View { + if !items.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: SodaSpacing.s12) { + ForEach(items, id: \.self) { item in + MainHomeLiveItemView(item: item, onTapLive: onTapLive) + } + } + .padding(.horizontal, SodaSpacing.s20) + } + } + } +} + +private struct MainHomeLiveItemView: View { + let item: HomeLiveItem + let onTapLive: (Int) -> Void + + var body: some View { + Button { + onTapLive(item.roomId) + } label: { + VStack(alignment: .center, spacing: SodaSpacing.s6) { + ZStack(alignment: .bottom) { + DownsampledKFImage(url: URL(string: item.creatorProfileImage), size: CGSize(width: 62, height: 62)) + .background(Color.gray800) + .clipShape(Circle()) + .padding(SodaSpacing.s4) + .overlay { + Circle() + .strokeBorder(Color.button, lineWidth: 3) + } + + Image("img_live") + .resizable() + .frame(width: 50, height: 18) + } + + Text(item.creatorNickname) + .appFont(.body4) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(width: 70, height: 102, alignment: .top) + } + .buttonStyle(.plain) + } +} + +struct MainHomeLiveSection_Previews: PreviewProvider { + private static let previewImageUrl = "https://placehold.co/500" + + static var previews: some View { + Group { + MainHomeLiveItemView( + item: HomeLiveItem(roomId: 1, creatorNickname: "크리에이...", creatorProfileImage: previewImageUrl), + onTapLive: { _ in } + ) + .background(Color.black) + .previewLayout(.fixed(width: 70, height: 102)) + .previewDisplayName("Live Item") + + MainHomeLiveSection( + items: [ + HomeLiveItem(roomId: 1, creatorNickname: "설린", creatorProfileImage: previewImageUrl), + HomeLiveItem(roomId: 2, creatorNickname: "크리에이터", creatorProfileImage: previewImageUrl) + ], + onTapLive: { _ in } + ) + .padding(.vertical, SodaSpacing.s20) + .background(Color.black) + .previewLayout(.sizeThatFits) + .previewDisplayName("Live Section") + } + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift index dc83b3a2..dc39e7f0 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift @@ -17,6 +17,16 @@ struct MainHomeRecommendationView: View { if viewModel.isLoading && viewModel.recommendations == nil { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: SodaSpacing.s24) { + MainHomeLiveSection( + items: viewModel.recommendations?.lives ?? [], + onTapLive: onTapLive + ) + } + .padding(.vertical, SodaSpacing.s20) + } } } .onAppear { diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift index fc00ec55..80a1f676 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift @@ -13,9 +13,9 @@ struct HomeRecommendationResponse: Decodable { } struct HomeLiveItem: Decodable, Hashable { - let roomId: Int? - let creatorNickname: String? - let creatorProfileImage: String? + let roomId: Int + let creatorNickname: String + let creatorProfileImage: String } struct RecommendationBannerResponse: Decodable, Hashable { @@ -30,56 +30,56 @@ struct RecommendationBannerResponse: Decodable, Hashable { } struct HomeActiveCreatorItem: Decodable, Hashable { - let creatorNickname: String? - let creatorProfileImage: String? - let activityType: RecommendedActivityType? - let activityAt: String? + let creatorNickname: String + let creatorProfileImage: String + let activityType: RecommendedActivityType + let activityAt: String let targetId: Int? } struct HomeCreatorItem: Decodable, Hashable { - let creatorId: Int? - let creatorNickname: String? - let creatorProfileImage: String? + let creatorId: Int + let creatorNickname: String + let creatorProfileImage: String } struct HomeFirstAudioContentItem: Decodable, Hashable { - let contentId: Int? - let creatorId: Int? - let creatorNickname: String? - let creatorProfileImage: String? - let title: String? - let price: Int? + let contentId: Int + let creatorId: Int + let creatorNickname: String + let creatorProfileImage: String + let title: String + let price: Int let coverImage: String? - let isPointAvailable: Bool? + let isPointAvailable: Bool } struct HomeAiCharacterItem: Decodable, Hashable { - let characterId: Int? - let creatorId: Int? - let name: String? - let description: String? + let characterId: Int + let creatorId: Int + let name: String + let description: String let profileImage: String? - let totalChatCount: Int? + let totalChatCount: Int let originalWorkTitle: String? } struct HomeGenreCreatorGroupItem: Decodable, Hashable { - let genreName: String? - let creators: [HomeCreatorItem]? + let genreName: String + let creators: [HomeCreatorItem] } struct HomePopularCommunityPostItem: Decodable, Hashable { - let postId: Int? - let creatorId: Int? - let creatorNickname: String? + let postId: Int + let creatorId: Int + let creatorNickname: String let creatorProfileImage: String? - let content: String? + let content: String let imageUrl: String? let audioUrl: String? - let price: Int? - let existOrdered: Bool? - let likeCount: Int? - let commentCount: Int? - let createdAt: String? + let price: Int + let existOrdered: Bool + let likeCount: Int + let commentCount: Int + let createdAt: String } diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index 41614645..d70d0559 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -440,7 +440,13 @@ struct MainView: View { private func handleRecommendationLiveTap(roomId: Int) { guard roomId > 0 else { return } performRecommendationDetailAction { - openLiveDetail(roomId: roomId) + handleExternalNavigationRequest( + value: roomId, + navigationAction: { + liveViewModel.enterLiveRoom(roomId: roomId) + }, + cancelAction: {} + ) } } diff --git a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md index c3a67fda..3823810a 100644 --- a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md +++ b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md @@ -227,16 +227,18 @@ ### Phase 8: 현재 라이브 섹션 UI -- [ ] **Task 8.1: 현재 라이브 섹션 작성/보완** +- [x] **Task 8.1: 현재 라이브 섹션 작성/보완** - 대상 파일: - 생성/보완: `SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeLiveSection.swift` - 작업 내용: - `HomeLiveItem.roomId`, `creatorNickname`, `creatorProfileImage`로 라이브 아이템을 표시한다. + - `roomId`는 응답 스펙상 항상 존재하는 값으로 취급한다. + - 섹션 제목은 표시하지 않는다. - 데이터가 비어 있으면 섹션 제목과 컨테이너를 표시하지 않는다. - - 아이템 터치 이동은 라우팅 규칙이 명확한 경우에만 연결한다. + - 아이템 터치 시 기존 `LiveViewModel.enterLiveRoom(roomId:)` 흐름을 재사용해 로그인 guard 이후 입장 전 결제 확인, 비밀방 비밀번호 dialog, `LiveRoomViewV2` 입장을 처리한다. - 검증 기준: - - 실행 명령: `rg "MainHomeLiveSection|roomId|creatorNickname|creatorProfileImage" SodaLive/Sources/V2/Main/Home/Recommendation` - - 기대 결과: 라이브 섹션이 신규 응답 필드를 기준으로 구성된다. + - 실행 명령: `rg "MainHomeLiveSection|roomId|creatorNickname|creatorProfileImage|enterLiveRoom|LivePaymentDialog|LiveRoomPasswordDialog" SodaLive/Sources/V2/Main/Home/Recommendation SodaLive/Sources/V2/Main/MainView.swift` + - 기대 결과: 라이브 섹션이 신규 응답 필드를 기준으로 구성되고, 추천 라이브 탭이 기존 라이브 입장 흐름을 재사용한다. ### Phase 9: 배너 섹션 UI @@ -442,6 +444,38 @@ ## 검증 기록 +### 2026-06-29 Phase 8.1 현재 라이브 섹션 구현 완료 + +- 목적: V2 Main Home 추천 탭에 현재 라이브 섹션을 API 응답 데이터 기반으로 표시 +- 수행 내용: + - `MainHomeLiveSection`을 `Recommendation/Components` 아래에 추가하고 `HomeLiveItem.roomId`, `creatorNickname`, `creatorProfileImage`를 사용해 렌더링 + - Figma `24:5518` 라이브 아이템 메타데이터(`70x102`)와 스크린샷을 기준으로 단일 아이템 프레임과 프로필 이미지/라이브 배지 배치를 보정 + - `MainHomeLiveSection_Previews`에 `Live Item` 고정 크기 Preview와 `Live Section` Preview를 추가해 Xcode Preview에서 확인 가능하도록 구성 + - 섹션 제목을 제거하고 `roomId`를 non-optional 응답 값으로 반영 + - 데이터가 비어 있으면 `SectionTitle`과 가로 목록이 렌더링되지 않도록 구성 + - `MainHomeRecommendationView`에는 Phase 8 현재 라이브 섹션만 조립하고, 아이템 탭 시 `MainView`에서 로그인 guard 후 `LiveViewModel.enterLiveRoom(roomId:)`로 전달되도록 연결 + - `MainView`에 이미 배치된 `LivePaymentDialog`, `LiveRoomPasswordDialog`, `LiveRoomViewV2` 표시 흐름을 재사용 + - 신규 Swift 파일을 `SodaLive.xcodeproj/project.pbxproj` 빌드 대상에 포함 +- 검증: + - Figma `24:5518` 메타데이터 확인 결과: `live` 노드 크기 `70x102`, 스크린샷 기준 원형 프로필/하단 `LIVE` 배지/닉네임 말줄임 구성 확인 + - `rg "MainHomeLiveSection|roomId|creatorNickname|creatorProfileImage|enterLiveRoom|LivePaymentDialog|LiveRoomPasswordDialog" SodaLive/Sources/V2/Main/Home/Recommendation SodaLive/Sources/V2/Main/MainView.swift` 실행, 라이브 섹션 필드와 기존 입장 흐름 재사용 확인 + - `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행, `OK` 확인 + - `git diff --check` 실행, 출력 없이 성공 확인 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인 + +### 2026-06-29 추천 응답 모델 optionality 정렬 + +- 목적: PRD의 "서버 nullable 필드는 Swift optional" 기준에 맞춰 `MainHomeRecommendationResponse.swift` 모델 optionality를 정리 +- 수행 내용: + - PRD 응답 스펙에서 `?`가 없는 필드를 Swift non-optional로 변경 + - `targetId`, `coverImage`, `profileImage`, `originalWorkTitle`, `creatorProfileImage`, `imageUrl`, `audioUrl` 등 PRD nullable 필드는 optional 유지 + - `RecommendationBannerResponse`는 상세 PRD 스펙이 별도로 확정되지 않아 기존 optional 선언 유지 + - `HomeLiveItem.creatorNickname`, `creatorProfileImage`가 non-optional이 되면서 라이브 섹션의 불필요한 nil fallback 제거 +- 검증: + - `rg -n "let (roomId|activityType|activityAt|contentId|isPointAvailable|characterId|totalChatCount|genreName|creators|postId|createdAt|likeCount|commentCount|existOrdered): .*\\?" SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift` 실행, 검색 결과 없음 확인 예정 + - `rg -n "\\?\\? \"\"" SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeLiveSection.swift` 실행, 검색 결과 없음 확인 예정 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행 예정 + ### 2026-06-29 Phase 7 중간 빌드 검증 완료 - 목적: Phase 4~6에서 생성/이동한 데이터 상태, 라우팅, 탭 루트 Swift 파일이 프로젝트 빌드 대상에 포함되어 있는지 중간 검증 diff --git a/docs/20260602_메인_홈_추천_UI_API_연동/prd.md b/docs/20260602_메인_홈_추천_UI_API_연동/prd.md index b8029c85..f95075ca 100644 --- a/docs/20260602_메인_홈_추천_UI_API_연동/prd.md +++ b/docs/20260602_메인_홈_추천_UI_API_연동/prd.md @@ -174,6 +174,7 @@ enum class RecommendedActivityType(val code: String) { - 본인인증 성공 후 `pendingAction`을 실행해 원래 상세 화면으로 이동한다. - 민감 콘텐츠 보기 설정이 꺼져 있으면 `AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)`로 안내 문구를 예약하고 `AppState.shared.setAppStep(step: .contentViewSettings)`로 이동한다. - 위 조건을 통과한 경우에만 `liveDetail`, `creatorDetail`, `contentDetail`, `characterDetail` 등 실제 상세 이동을 실행한다. +- 추천 탭 현재 라이브 아이템은 `roomId`가 항상 존재하는 값으로 취급하고, 아이템 탭 시 상세 화면을 거치지 않고 기존 `LiveViewModel.enterLiveRoom(roomId:)` 흐름을 호출해 방 상세 조회, 입장 전 결제 확인, 비밀방 비밀번호 dialog, `LiveRoomViewV2` 입장을 재사용한다. 라이브 항목 주의사항: - 기존 홈의 라이브 guard는 `roomId`와 `isAdult`를 함께 받아 성인 여부를 판단한다. diff --git a/docs/agent-guides/code-style.md b/docs/agent-guides/code-style.md index df8ea420..3fdf2f2e 100644 --- a/docs/agent-guides/code-style.md +++ b/docs/agent-guides/code-style.md @@ -33,6 +33,9 @@ - 프로퍼티 선언, 비즈니스 로직, 헬퍼 메서드는 공백 줄로 구획한다. - 클로저 체인은 줄바꿈해 가독성을 유지한다. +## SwiftUI Preview 규칙 +- Preview에서 이미지 URL 샘플이 필요하면 `https://placehold.co/500`를 사용한다. + ## 타입/상태 관리 - ViewModel은 `final class ...: ObservableObject` 패턴을 우선한다. - View가 소유하는 객체는 `@StateObject`, 외부 주입 객체는 `@ObservedObject`를 사용한다.