diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 56df336b..dc1f57cb 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -214,8 +214,11 @@ 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 */; }; + 401A3FC62FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */; }; 401A3FC32FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; }; + 401A3FC72FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */; }; 401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; }; + 401A3FC82FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.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 */; }; @@ -1730,6 +1733,7 @@ 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 = ""; }; + 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeBannerSection.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 = ""; }; @@ -2702,6 +2706,7 @@ isa = PBXGroup; children = ( 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */, + 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */, ); path = Components; sourceTree = ""; @@ -5328,6 +5333,7 @@ 401A3FA82FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FA92FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, 401A3FC22FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */, + 401A3FC62FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */, 401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, 409143302A85652F00EEBFB1 /* CanChargeView.swift in Sources */, @@ -6075,6 +6081,7 @@ 401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, 401A3FC32FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */, + 401A3FC72FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */, 401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */, @@ -6849,6 +6856,7 @@ 401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, 401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */, + 401A3FC82FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */, 401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, ACD71056E97E13E48941ABBB /* MainPlaceholderTabView.swift in Sources */, diff --git a/SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift b/SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift index 1a41b67d..8ab5de74 100644 --- a/SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift +++ b/SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift @@ -11,48 +11,205 @@ struct BannerCarouselItem: Identifiable, Hashable { } struct BannerCarousel: View { + private static let maxItemCount = 20 + private static let autoScrollInterval: TimeInterval = 5 + private static let transitionDuration: TimeInterval = 0.35 + let items: [BannerCarouselItem] - let height: CGFloat let action: (BannerCarouselItem) -> Void + @State private var displayIndex = 1 + @GestureState private var dragOffset: CGFloat = 0 + @State private var autoScrollTimer: Timer? + @State private var isSwiping = false + init( items: [BannerCarouselItem], height: CGFloat = 120, action: @escaping (BannerCarouselItem) -> Void = { _ in } ) { - self.items = items - self.height = height + self.items = Array(items.prefix(BannerCarousel.maxItemCount)) self.action = action } var body: some View { if !items.isEmpty { - TabView { - ForEach(items) { item in - Button { - action(item) - } label: { - DownsampledKFImage( - url: URL(string: item.imageUrl ?? ""), - size: CGSize(width: UIScreen.main.bounds.width - (SodaSpacing.s20 * 2), height: height) - ) - .background(Color.gray800) - .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + GeometryReader { proxy in + let itemWidth = proxy.size.width - (SodaSpacing.s20 * 2) + let itemHeight = itemWidth + let step = itemWidth + SodaSpacing.s8 + let leadingInset = (proxy.size.width - itemWidth) / 2 + let effectiveIndex = items.count > 1 ? displayIndex : 0 + let offset = leadingInset - (CGFloat(effectiveIndex) * step) + dragOffset + + HStack(spacing: SodaSpacing.s8) { + ForEach(Array(visibleItems.enumerated()), id: \.offset) { _, item in + Button { + guard !isSwiping else { return } + action(item) + } label: { + DownsampledKFImage( + url: URL(string: item.imageUrl ?? ""), + size: CGSize(width: itemWidth, height: itemHeight) + ) + .background(Color.gray800) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + .buttonStyle(.plain) + .frame(width: itemWidth, height: itemHeight) } - .buttonStyle(.plain) - .padding(.horizontal, SodaSpacing.s20) + } + .offset(x: offset) + .frame(width: proxy.size.width, height: itemHeight, alignment: .leading) + .animation(.easeOut(duration: BannerCarousel.transitionDuration), value: displayIndex) + .clipped() + .overlay(alignment: .topTrailing) { + pageIndicator + .padding(.top, SodaSpacing.s14) + .padding(.trailing, SodaSpacing.s20 + SodaSpacing.s14) + } + .contentShape(Rectangle()) + .simultaneousGesture(dragGesture(step: step)) + .onChange(of: items) { _ in + displayIndex = items.count > 1 ? 1 : 0 + restartAutoScrollTimer() + } + } + .frame(height: UIScreen.main.bounds.width - (SodaSpacing.s20 * 2)) + .onAppear { + displayIndex = items.count > 1 ? 1 : 0 + restartAutoScrollTimer() + } + .onDisappear { + stopAutoScrollTimer() + } + } + } + + private var visibleItems: [BannerCarouselItem] { + guard items.count > 1, let first = items.first, let last = items.last else { + return items + } + + return [last] + items + [first] + } + + private var currentPage: Int { + guard items.count > 1 else { return items.isEmpty ? 0 : 1 } + + if displayIndex == 0 { + return items.count + } + + if displayIndex == items.count + 1 { + return 1 + } + + return displayIndex + } + + private var pageIndicator: some View { + HStack(alignment: .center, spacing: 1) { + Text(String(format: "%02d", currentPage)) + .foregroundColor(.white) + .frame(width: 20) + + Text("/") + .foregroundColor(.gray400) + + Text(String(format: "%02d", items.count)) + .foregroundColor(.gray400) + .frame(width: 20) + } + .appFont(.body3) + .padding(.horizontal, SodaSpacing.s6) + .padding(.vertical, SodaSpacing.s4) + .background(Color.black.opacity(0.7)) + .clipShape(Capsule()) + } + + private func dragGesture(step: CGFloat) -> some Gesture { + DragGesture(minimumDistance: 10) + .updating($dragOffset) { value, state, _ in + state = value.translation.width + } + .onChanged { _ in + if !isSwiping { + isSwiping = true + } + } + .onEnded { value in + defer { + DispatchQueue.main.async { + isSwiping = false + } + } + + guard items.count > 1 else { return } + + let threshold = step * 0.2 + if value.translation.width < -threshold { + displayIndex += 1 + } else if value.translation.width > threshold { + displayIndex -= 1 + } + + wrapIfNeeded() + restartAutoScrollTimer() + } + } + + private func restartAutoScrollTimer() { + stopAutoScrollTimer() + guard items.count > 1 else { return } + + autoScrollTimer = Timer.scheduledTimer( + withTimeInterval: BannerCarousel.autoScrollInterval, + repeats: true + ) { _ in + displayIndex += 1 + wrapIfNeeded() + } + } + + private func stopAutoScrollTimer() { + autoScrollTimer?.invalidate() + autoScrollTimer = nil + } + + private func wrapIfNeeded() { + guard items.count > 1 else { return } + + if displayIndex == 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + displayIndex = items.count + } + } + } else if displayIndex == items.count + 1 { + DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + displayIndex = 1 } } - .frame(height: height) - .tabViewStyle(.page(indexDisplayMode: items.count > 1 ? .automatic : .never)) } } } struct BannerCarousel_Previews: PreviewProvider { static var previews: some View { - BannerCarousel(items: [BannerCarouselItem(id: "1", imageUrl: nil)]) - .background(Color.black) - .previewLayout(.sizeThatFits) + BannerCarousel( + items: [ + BannerCarouselItem(id: "1", imageUrl: "https://picsum.photos/500/500"), + BannerCarouselItem(id: "2", imageUrl: "https://picsum.photos/500/500"), + BannerCarouselItem(id: "3", imageUrl: "https://picsum.photos/500/500") + ] + ) + .background(Color.black) + .previewLayout(.sizeThatFits) } } diff --git a/SodaLive/Sources/V2/Main/Home/MainHomeView.swift b/SodaLive/Sources/V2/Main/Home/MainHomeView.swift index 902d652f..f832454c 100644 --- a/SodaLive/Sources/V2/Main/Home/MainHomeView.swift +++ b/SodaLive/Sources/V2/Main/Home/MainHomeView.swift @@ -9,6 +9,7 @@ struct MainHomeView: View { let onTapContent: (Int) -> Void let onTapCharacter: (Int) -> Void let onTapCommunity: (Int) -> Void + let onTapBanner: (RecommendationBannerResponse) -> Void @State private var selectedTab: MainHomeTab = .recommendation @@ -48,7 +49,8 @@ struct MainHomeView: View { onTapCreator: onTapCreator, onTapContent: onTapContent, onTapCharacter: onTapCharacter, - onTapCommunity: onTapCommunity + onTapCommunity: onTapCommunity, + onTapBanner: onTapBanner ) case .ranking: MainHomeRankingView() @@ -85,7 +87,8 @@ struct MainHomeView_Previews: PreviewProvider { onTapCreator: { _ in }, onTapContent: { _ in }, onTapCharacter: { _ in }, - onTapCommunity: { _ in } + onTapCommunity: { _ in }, + onTapBanner: { _ in } ) } } diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeBannerSection.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeBannerSection.swift new file mode 100644 index 00000000..4ac20917 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeBannerSection.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct MainHomeBannerSection: View { + let items: [RecommendationBannerResponse] + let onTapBanner: (RecommendationBannerResponse) -> Void + + init( + items: [RecommendationBannerResponse], + onTapBanner: @escaping (RecommendationBannerResponse) -> Void = { _ in } + ) { + self.items = items + self.onTapBanner = onTapBanner + } + + var body: some View { + if !items.isEmpty { + BannerCarousel(items: carouselItems) { item in + guard let banner = banner(for: item) else { return } + onTapBanner(banner) + } + } + } + + private var carouselItems: [BannerCarouselItem] { + items.enumerated().map { index, item in + BannerCarouselItem( + id: fallbackId(for: item, index: index), + imageUrl: item.imageUrl + ) + } + } + + private func banner(for carouselItem: BannerCarouselItem) -> RecommendationBannerResponse? { + items.enumerated().first { index, item in + fallbackId(for: item, index: index) == carouselItem.id + }?.element + } + + private func fallbackId(for item: RecommendationBannerResponse, index: Int) -> String { + if let eventItem = item.eventItem { + return "banner-\(index)-event-\(eventItem.id)" + } + + if let creatorId = item.creatorId { + return "banner-\(index)-creator-\(creatorId)" + } + + if let seriesId = item.seriesId { + return "banner-\(index)-series-\(seriesId)" + } + + if let link = item.link, !link.isEmpty { + return "banner-\(index)-link-\(link)" + } + + return "banner-\(index)-image-\(item.imageUrl)" + } +} + +struct MainHomeBannerSection_Previews: PreviewProvider { + static var previews: some View { + MainHomeBannerSection( + items: [ + RecommendationBannerResponse( + imageUrl: "https://picsum.photos/500/500", + eventItem: nil, + creatorId: nil, + seriesId: nil, + link: nil + ), + RecommendationBannerResponse( + imageUrl: "https://picsum.photos/500/500", + eventItem: nil, + creatorId: 1, + seriesId: nil, + link: nil + ) + ] + ) + .background(Color.black) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeLiveSection.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeLiveSection.swift index 2e3d23d4..926102ef 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeLiveSection.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeLiveSection.swift @@ -55,7 +55,7 @@ private struct MainHomeLiveItemView: View { } struct MainHomeLiveSection_Previews: PreviewProvider { - private static let previewImageUrl = "https://placehold.co/500" + private static let previewImageUrl = "https://picsum.photos/500/500" static var previews: some View { Group { diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift index dc39e7f0..532789e6 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift @@ -6,6 +6,7 @@ struct MainHomeRecommendationView: View { let onTapContent: (Int) -> Void let onTapCharacter: (Int) -> Void let onTapCommunity: (Int) -> Void + let onTapBanner: (RecommendationBannerResponse) -> Void @StateObject private var viewModel = MainHomeRecommendationViewModel() @@ -24,6 +25,11 @@ struct MainHomeRecommendationView: View { items: viewModel.recommendations?.lives ?? [], onTapLive: onTapLive ) + + MainHomeBannerSection( + items: viewModel.recommendations?.banners ?? [], + onTapBanner: onTapBanner + ) } .padding(.vertical, SodaSpacing.s20) } @@ -49,7 +55,8 @@ struct MainHomeRecommendationView_Previews: PreviewProvider { onTapCreator: { _ in }, onTapContent: { _ in }, onTapCharacter: { _ in }, - onTapCommunity: { _ in } + onTapCommunity: { _ in }, + onTapBanner: { _ in } ) } } diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift index 80a1f676..15920f74 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift @@ -19,14 +19,11 @@ struct HomeLiveItem: Decodable, Hashable { } struct RecommendationBannerResponse: Decodable, Hashable { - let bannerId: Int? - let type: String? - let title: String? - let imageUrl: String? - let link: String? - let eventId: Int? + let imageUrl: String + let eventItem: EventItem? let creatorId: Int? let seriesId: Int? + let link: String? } struct HomeActiveCreatorItem: Decodable, Hashable { diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index d70d0559..b9a630de 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -161,7 +161,8 @@ struct MainView: View { onTapCreator: handleRecommendationCreatorTap, onTapContent: handleRecommendationContentTap, onTapCharacter: handleRecommendationCharacterTap, - onTapCommunity: handleRecommendationCommunityTap + onTapCommunity: handleRecommendationCommunityTap, + onTapBanner: handleRecommendationBannerTap ) case .content: MainPlaceholderTabView(title: MainTab.content.title) @@ -478,6 +479,41 @@ struct MainView: View { } } + + private func handleRecommendationBannerTap(_ item: RecommendationBannerResponse) { + if let eventItem = item.eventItem { + performRecommendationDetailAction { + appState.setAppStep(step: .eventDetail(event: eventItem)) + } + return + } + + if let creatorId = item.creatorId { + handleRecommendationCreatorTap(creatorId: creatorId) + return + } + + if let seriesId = item.seriesId { + performRecommendationDetailAction { + appState.setAppStep(step: .seriesDetail(seriesId: seriesId)) + } + return + } + + openRecommendationBannerLink(item.link) + } + + private func openRecommendationBannerLink(_ link: String?) { + guard let link = link?.trimmingCharacters(in: .whitespacesAndNewlines), + !link.isEmpty, + let url = URL(string: link), + UIApplication.shared.canOpenURL(url) else { + return + } + + UIApplication.shared.open(url) + } + private func performRecommendationDetailAction( requiresAdultContent: Bool = false, action: @escaping () -> Void diff --git a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md index 3823810a..2ae96e06 100644 --- a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md +++ b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md @@ -242,18 +242,70 @@ ### Phase 9: 배너 섹션 UI -- [ ] **Task 9.1: 추천 배너 섹션 작성/보완** +- [x] **Task 9.1: BannerCarousel 현재 구현과 Figma 요구사항 고정** - 대상 파일: - - 생성/보완: `SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeBannerSection.swift` - 확인/보완: `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift` - 확인/보완: `SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift` - 작업 내용: - - `banners: List`를 표시할 Swift 모델을 확정한다. - - 기존 `BannerCarousel`로 충분하면 재사용하고, 홈 전용 wrapper만 작성한다. + - Figma 기준 노드는 `24:5525`로 고정한다. + - `BannerCarousel`이 현재 `TabView(.page)` 기반 단일 페이지 노출 구조인지 확인한다. + - `RecommendationBannerResponse`의 `imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link`가 표시와 이동에 매핑 가능한지 확인한다. - 배너 이동 규칙은 미확정이면 탭 액션을 임의 구현하지 않는다. - 검증 기준: - - 실행 명령: `rg "RecommendationBannerResponse|HomeBanner|BannerCarousel|MainHomeBannerSection" SodaLive/Sources/V2/Main/Home/Recommendation SodaLive/Sources/V2/Component/Banner` - - 기대 결과: 배너 응답 모델과 화면 조합이 확인된다. + - 실행 명령: `rg "struct BannerCarousel|BannerCarouselItem|RecommendationBannerResponse|eventItem|creatorId|seriesId|link|imageUrl" SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift` + - 기대 결과: 기존 캐러셀 구조와 배너 응답 모델 필드를 확인할 수 있다. + +- [x] **Task 9.2: BannerCarousel을 Figma와 동일한 수평 캐러셀로 보완** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift` + - 작업 내용: + - 배너 width는 `device width - 40`으로 계산한다. + - 배너 이미지 영역은 1:1 비율을 유지한다. + - 배너 사이 간격은 8로 둔다. + - 현재 배너 좌우에 이전/다음 배너가 조금씩 보이도록 수평 inset과 item 배치를 조정한다. + - 배너가 2개 이상이면 무한 스크롤이 가능하도록 첫/마지막 경계에서 자연스럽게 이어지게 처리한다. + - 배너 corner radius는 Figma 기준 `14`를 유지한다. + - Figma처럼 우측 상단에 현재/전체 페이지 인디케이터를 표시한다. + - 인디케이터는 `01 / 20` 형식으로 두 자리 current/total을 표시하고, 배경은 반투명 검정 pill 형태로 둔다. + - 로컬 Figma asset URL(`localhost:3845`)은 앱 코드에 넣지 않는다. + - 검증 기준: + - 실행 명령: `rg "SodaSpacing.s8|SodaSpacing.s14|BannerCarousel|GeometryReader|ScrollView|TabView|01|current|total|localhost:3845" SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift` + - 기대 결과: 간격 8, radius 14, 페이지 인디케이터, 무한/수평 캐러셀 구현 근거가 확인되고 `localhost:3845`는 검색되지 않아야 한다. + - 수동 확인: iPhone 15 기준 현재 배너 폭이 화면 폭보다 40 작고, 좌우에 이전/다음 배너 일부가 보여야 한다. + - 수동 확인: 이미지 영역이 정사각형으로 표시되고 배너 간 간격이 8로 보여야 한다. + +- [x] **Task 9.3: 추천 홈 배너 섹션 작성/보완** + - 대상 파일: + - 생성/보완: `SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeBannerSection.swift` + - 수정: `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift` + - 확인/보완: `SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift` + - 작업 내용: + - `HomeRecommendationResponse.banners`를 `BannerCarouselItem` 배열로 변환한다. + - `eventItem`, `creatorId`, `seriesId`, `link`, `imageUrl` 순서로 안정적인 carousel id를 만든다. + - `imageUrl`이 비어 있는 배너는 이미지 placeholder 정책을 기존 `BannerCarousel` 동작과 맞춘다. + - 데이터가 비어 있으면 배너 섹션 자체를 표시하지 않는다. + - 기존 `MainHomeRecommendationView` 섹션 순서를 유지하면서 현재 라이브 섹션 다음 위치에 배너 섹션을 배치한다. + - 배너 이동 규칙은 미확정이면 탭 액션을 임의 구현하지 않는다. + - 검증 기준: + - 실행 명령: `rg "MainHomeBannerSection|BannerCarouselItem|BannerCarousel|banners|RecommendationBannerResponse" SodaLive/Sources/V2/Main/Home/Recommendation SodaLive/Sources/V2/Component/Banner` + - 기대 결과: 추천 응답의 `banners`가 홈 배너 섹션을 통해 `BannerCarousel`에 연결되어야 한다. + +- [x] **Task 9.4: 배너 섹션 빌드 및 시각 회귀 검증** + - 대상 파일: + - 확인: `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift` + - 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeBannerSection.swift` + - 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift` + - 확인: `SodaLive.xcodeproj/project.pbxproj` + - 작업 내용: + - 새로 만든 `MainHomeBannerSection.swift`가 빌드 대상에 포함되는지 확인한다. + - 파일 시스템 동기화 방식으로 자동 포함되면 프로젝트 파일을 수정하지 않는다. + - 프로젝트 파일 수정이 필요한 경우에만 최소 변경한다. + - 검증 기준: + - 실행 명령: `plutil -lint SodaLive.xcodeproj/project.pbxproj` + - 기대 결과: `OK` + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 기대 결과: `BUILD SUCCEEDED` + - 수동 확인: 추천 홈에서 배너가 Figma 노드 `24:5525`처럼 좌우 배너 일부 노출, 간격 8, 1:1 이미지, 우측 상단 인디케이터 형태로 표시되어야 한다. ### Phase 10: 최근 활동 크리에이터 섹션 UI @@ -444,6 +496,57 @@ ## 검증 기록 +### 2026-06-29 Phase 9 배너 스펙 변경 반영 + +- 목적: 변경된 `RecommendationBannerResponse` 백엔드 응답과 배너 1:1 이미지 비율 요구사항을 반영 +- 수행 내용: + - `RecommendationBannerResponse`에서 과거 `bannerId`, `type`, `title`, `eventId` 필드를 제거하고 `imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link` 구조로 변경 + - 배너 탭 시 `eventItem`, `creatorId`, `seriesId`, `link` 순서로 첫 non-optional 목적지를 찾아 이동하도록 연결 + - `eventItem`은 `eventDetail`, `creatorId`는 `creatorDetail`, `seriesId`는 `seriesDetail`, `link`는 외부 URL open 경로를 사용 + - `BannerCarousel` 높이를 `itemWidth`와 동일하게 되돌려 이미지 영역 1:1 비율 유지 + - `BannerCarousel_Previews`, `MainHomeBannerSection_Previews`, `MainHomeLiveSection_Previews`의 Preview 원격 이미지를 `placehold.co` 대신 JPEG를 반환하는 `picsum.photos/500/500`로 변경 +- 검증: + - `rg -n "bannerId|eventId|RecommendationBannerResponse\(|\.type|\.title" SodaLive/Sources/V2/Main/Home SodaLive/Sources/V2/Main/MainView.swift` 실행, V2 추천 배너 코드에서 과거 응답 필드 참조 제거 확인 + - `curl -L -o /dev/null -w "%{http_code} %{content_type}\n" "https://placehold.co/500"` 실행, `200 image/svg+xml` 확인 + - `curl -L -o /dev/null -w "%{http_code} %{content_type}\n" "https://picsum.photos/500/500"` 실행, `200 image/jpeg` 확인 + - `rg -n "placehold\.co/500" SodaLive/Sources/V2/Main/Home/Recommendation/Components SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift` 실행, 검색 결과 없음 확인 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인 + - `git diff --check` 실행, 출력 없이 성공 확인 + +### 2026-06-29 Phase 9 배너 섹션 구현 완료 + +- 목적: V2 Main Home 추천 탭에 Figma `24:5525` 기준 배너 캐러셀과 추천 배너 섹션을 연결 +- 수행 내용: + - `BannerCarousel`을 `device width - 40` 폭의 1:1 배너, 간격 8, radius 14, 좌우 인접 배너 일부 노출, 우측 상단 `01 / NN` pill 인디케이터 구조로 보완 + - 배너가 2개 이상이면 첫/마지막에 복제 아이템을 두어 경계에서 이어지는 수평 캐러셀 동작을 구현 + - `MainHomeBannerSection`을 추가해 `RecommendationBannerResponse.imageUrl`을 `BannerCarouselItem`으로 매핑하고, 원본 배너를 상위 탭 콜백으로 전달하며, 데이터가 비어 있으면 섹션을 렌더링하지 않도록 구성 + - `MainHomeRecommendationView`에서 현재 라이브 섹션 다음에 배너 섹션을 배치 + - 배너 이동 규칙은 미확정 상태로 유지하고 탭 액션을 새로 구현하지 않음 + - 리뷰어 지적에 따라 무한 캐러셀 경계 reset은 animation 비활성 transaction으로 처리하고, 인디케이터 trailing 위치를 현재 배너 내부 기준으로 보정 + - 신규 `MainHomeBannerSection.swift`를 `SodaLive.xcodeproj/project.pbxproj` 빌드 대상에 포함 +- 검증: + - Figma `24:5525` 디자인 컨텍스트와 스크린샷 확인, 3개 수평 배너/간격 8/radius 14/우측 상단 `01 / 20` 인디케이터 확인 + - `rg "SodaSpacing\.s8|SodaSpacing\.s14|BannerCarousel|GeometryReader|ScrollView|TabView|01|current|total|localhost:3845" SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift` 실행, 간격/radius/수평 캐러셀/페이지 인디케이터 근거 확인 및 `localhost:3845` 미포함 확인 + - `rg "MainHomeBannerSection|BannerCarouselItem|BannerCarousel|banners|RecommendationBannerResponse" SodaLive/Sources/V2/Main/Home/Recommendation SodaLive/Sources/V2/Component/Banner` 실행, 추천 응답 `banners`가 배너 섹션과 `BannerCarousel`에 연결됨 확인 + - `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 Phase 9 배너 섹션 Task 세분화 + +- 목적: `BannerCarousel`을 Figma `24:5525` 기준으로 구현할 수 있도록 Phase 9 작업을 실행 가능한 단위로 세분화 +- 수행 내용: + - 기존 단일 `Task 9.1`을 `Task 9.1`~`Task 9.4`로 분리 + - `BannerCarousel` 현재 구조 확인, Figma 레이아웃 보완, 추천 홈 배너 섹션 연결, 빌드/시각 검증 단계로 분리 + - 무한 스크롤, 좌우 배너 일부 노출, 배너 간격 8, 이미지 1:1 비율, 배너 width `device width - 40`, 우측 상단 페이지 인디케이터 요구사항 반영 + - Figma 컨텍스트에서 확인한 radius 14와 `01 / 20` 형태 인디케이터를 구현 요구사항에 반영 +- 검증: + - Figma `24:5525` 디자인 컨텍스트와 스크린샷 확인, 수평 배너 3개 구성과 우측 상단 `01 / 20` 인디케이터 확인 + - `rg "Task 9\\.[1-4]|device width - 40|1:1|간격은 8|무한 스크롤|24:5525|01 / 20" docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md` 실행, Phase 9 세부 Task와 핵심 배너 요구사항 반영 확인 + - `rg "### Phase [0-9]+:|- \\[[ x]\\] \\*\\*Task [0-9]+\\.[0-9]+:" docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md` 실행, Phase/Task 형식 확인 + - `git diff --check -- docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md` 실행, 출력 없이 성공 확인 + ### 2026-06-29 Phase 8.1 현재 라이브 섹션 구현 완료 - 목적: V2 Main Home 추천 탭에 현재 라이브 섹션을 API 응답 데이터 기반으로 표시 @@ -469,7 +572,7 @@ - 수행 내용: - PRD 응답 스펙에서 `?`가 없는 필드를 Swift non-optional로 변경 - `targetId`, `coverImage`, `profileImage`, `originalWorkTitle`, `creatorProfileImage`, `imageUrl`, `audioUrl` 등 PRD nullable 필드는 optional 유지 - - `RecommendationBannerResponse`는 상세 PRD 스펙이 별도로 확정되지 않아 기존 optional 선언 유지 + - `RecommendationBannerResponse`는 백엔드 변경 스펙에 맞춰 `imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link` 기준으로 정렬 - `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` 실행, 검색 결과 없음 확인 예정 @@ -512,7 +615,7 @@ - 목적: Phase 2.2 리뷰에서 지적된 과거 추정 응답 필드 혼재를 제거하고, 추천 전용 request 모델 위치를 `Recommendation/Models` 구조로 일치 - 수행 내용: - `MainHomeRecommendationResponse.swift`에서 PRD 응답 스펙에 없는 legacy/추정 필드(`liveRoomId`, `profileImageUrl`, `thumbnailUrl`, `coverImageUrl`, `releaseDate`, `originalTitle` 등) 제거 - - 배너는 상세 PRD 스펙이 별도로 없으므로 기존 표시/이동 판단에 필요한 `RecommendationBannerResponse` 최소 필드 유지 + - 배너는 변경된 백엔드 스펙에 맞춰 `eventItem`, `creatorId`, `seriesId`, `link` 중 첫 non-optional 값으로 이동 판단 - `FollowRecommendedCreatorsRequest.swift`를 `SodaLive/Sources/V2/Main/Home/Recommendation/Models` 아래로 이동 - `SodaLive.xcodeproj/project.pbxproj`의 request/model/API/Repository 참조 경로 갱신 - 검증: diff --git a/docs/20260602_메인_홈_추천_UI_API_연동/prd.md b/docs/20260602_메인_홈_추천_UI_API_연동/prd.md index f95075ca..1dd3a5f2 100644 --- a/docs/20260602_메인_홈_추천_UI_API_연동/prd.md +++ b/docs/20260602_메인_홈_추천_UI_API_연동/prd.md @@ -120,6 +120,25 @@ data class HomeGenreCreatorGroupItem( val creators: List ) + + +#### RecommendationBannerResponse +```kotlin +data class RecommendationBannerResponse( + val imageUrl: String, + val eventItem: EventItem?, + val creatorId: Long?, + val seriesId: Long?, + val link: String? +) +``` + +배너 탭 이동은 `eventItem`, `creatorId`, `seriesId`, `link` 순서로 첫 non-null 값을 사용한다. +- `eventItem` -> 이벤트 상세 +- `creatorId` -> 크리에이터 상세 +- `seriesId` -> 시리즈 상세 +- `link` -> 외부 URL 열기 + data class HomePopularCommunityPostItem( val postId: Long, val creatorId: Long, @@ -312,7 +331,7 @@ MainHomeView - 빌드가 성공하고, 가능하면 ViewModel 디코딩/상태 전환 검증을 수행한다. ## 11. Open Questions -- `RecommendationBannerResponse`의 필드 목록과 배너 `type`별 이동 규칙 확인이 필요하다. +- `RecommendationBannerResponse`는 변경된 백엔드 스펙 기준으로 `eventItem`, `creatorId`, `seriesId`, `link` 순서의 이동 규칙을 사용한다. - 각 섹션별 최대 표시 개수와 가로/세로 스크롤 정책이 Figma 기준 그대로인지, 서버 응답 전체를 모두 표시해야 하는지 확인이 필요하다. - `HomeFirstAudioContentItem`에서 오리지널 작품 여부와 크리에이터의 첫 번째 콘텐츠 여부를 판단할 서버 필드가 추가되는지 확인이 필요하다. - `HomeLiveItem`에 민감/성인 라이브 여부를 판단할 `isAdult` 또는 동등한 필드가 추가되는지, 아니면 서버에서 추천 라이브를 사전 필터링하는지 확인이 필요하다.