diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index b06c5a14..3030567d 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -31,6 +31,9 @@ CC0101162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; }; CC0102162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; }; CC0103162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; }; + CC0101172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */; }; + CC0102172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */; }; + CC0103172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */; }; CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; }; CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; }; CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; }; @@ -1758,6 +1761,7 @@ CC0100142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCurrentLiveSection.swift; sourceTree = ""; }; CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLatestAudioSection.swift; sourceTree = ""; }; CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationSection.swift; sourceTree = ""; }; + CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelNoticeSection.swift; sourceTree = ""; }; 0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = ""; }; 0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = ""; }; 189350EA101CF12EEBA6EC1D /* MainTabBarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTabBarView.swift; sourceTree = ""; }; @@ -2680,6 +2684,7 @@ CC0100142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift */, CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */, CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */, + CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */, ); path = Components; sourceTree = ""; @@ -5617,6 +5622,7 @@ CC0101142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */, CC0101152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */, CC0101162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */, + CC0101172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */, CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, @@ -6417,6 +6423,7 @@ CC0102142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */, CC0102152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */, CC0102162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */, + CC0102172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */, CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, @@ -7233,6 +7240,7 @@ CC0103142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */, CC0103152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */, CC0103162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */, + CC0103172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */, CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelNoticeSection.swift b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelNoticeSection.swift new file mode 100644 index 00000000..2a22fd42 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelNoticeSection.swift @@ -0,0 +1,290 @@ +import SwiftUI + +struct CreatorChannelNoticeSection: View { + let notices: [CreatorChannelCommunityPostResponse] + let onTapNotice: (Int) -> Void + let onTapComment: (Int) -> Void + let onTapPurchase: (CreatorChannelCommunityPostResponse) -> Void + + init( + notices: [CreatorChannelCommunityPostResponse], + onTapNotice: @escaping (Int) -> Void = { _ in }, + onTapComment: @escaping (Int) -> Void = { _ in }, + onTapPurchase: @escaping (CreatorChannelCommunityPostResponse) -> Void = { _ in } + ) { + self.notices = notices + self.onTapNotice = onTapNotice + self.onTapComment = onTapComment + self.onTapPurchase = onTapPurchase + } + + var body: some View { + if !notices.isEmpty { + VStack(alignment: .leading, spacing: SodaSpacing.s14) { + SectionTitle(title: I18n.CreatorChannelHome.notice) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: SodaSpacing.s4) { + ForEach(notices) { notice in + CreatorChannelNoticeCard( + notice: notice, + onTapNotice: { + onTapNotice(notice.postId) + }, + onTapComment: { + onTapComment(notice.postId) + }, + onTapPurchase: { + onTapPurchase(notice) + } + ) + .frame(width: 374) + } + } + .padding(.horizontal, SodaSpacing.s14) + } + } + .padding(.top, SodaSpacing.s20) + } + } +} + +private struct CreatorChannelNoticeCard: View { + let notice: CreatorChannelCommunityPostResponse + let onTapNotice: () -> Void + let onTapComment: () -> Void + let onTapPurchase: () -> Void + + @StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared + @StateObject private var contentPlayManager = ContentPlayManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s14) { + noticeLabel + header + bodyContent + reactionBar + } + .padding(SodaSpacing.s14) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + .contentShape(Rectangle()) + .onTapGesture(perform: onTapNotice) + } + + private var noticeLabel: some View { + HStack(spacing: 2) { + Image("ic_pin") + .resizable() + .renderingMode(.template) + .foregroundColor(Color(hex: "73FF01")) + .scaledToFit() + .frame(width: 18, height: 18) + + Text("Notice") + .appFont(size: 14, weight: .medium) + .foregroundColor(Color(hex: "73FF01")) + .lineLimit(1) + } + } + + private var header: some View { + HStack(spacing: SodaSpacing.s8) { + DownsampledKFImage( + url: URL(string: notice.creatorProfileUrl), + size: CGSize(width: 42, height: 42) + ) + .background(Color.white.opacity(0.6)) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text(notice.creatorNickname) + .appFont(size: 14, weight: .medium) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + + Text(notice.relativeTimeText()) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray500) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var bodyContent: some View { + HStack(alignment: .center, spacing: SodaSpacing.s8) { + Text(notice.content) + .appFont(size: 16, weight: .regular) + .foregroundColor(.white) + .lineLimit(4) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if notice.hasImage { + thumbnail + } + } + .frame(minHeight: 92, alignment: .center) + } + + private var thumbnail: some View { + ZStack { + DownsampledKFImage( + url: URL(string: notice.imageUrl ?? ""), + size: CGSize(width: 92, height: 92) + ) + .background(Color.gray800) + .frame(width: 92, height: 92) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + .blur(radius: notice.isLocked ? 12 : 0) + + if notice.isLocked { + paidLockOverlay + } else if notice.hasAudio { + audioPlayButton + } + } + } + + private var audioPlayButton: some View { + Button { + guard let audioUrl = notice.audioUrl, !audioUrl.isEmpty else { return } + + contentPlayManager.pauseAudio() + playManager.toggleContent(item: CreatorCommunityContentItem(contentId: notice.postId, url: audioUrl)) + } label: { + Image(playManager.isPlaying && playManager.currentPlayingContentId == notice.postId ? "btn_audio_content_pause" : "btn_audio_content_play") + .resizable() + .frame(width: 48, height: 48) + } + .buttonStyle(.plain) + } + + private var paidLockOverlay: some View { + Button(action: onTapPurchase) { + ZStack { + Color.gray700.opacity(0.35) + + VStack(spacing: SodaSpacing.s4) { + Image("ic_new_community_lock") + .resizable() + .renderingMode(.template) + .foregroundColor(.white) + .frame(width: 24, height: 24) + + HStack(spacing: SodaSpacing.s6) { + Image("ic_bar_cash") + .resizable() + .frame(width: 20, height: 20) + + Text("\(notice.price)") + .appFont(.body2) + .foregroundColor(.black) + } + .padding(SodaSpacing.s8) + .background(Color.white) + .clipShape(Capsule()) + } + } + } + .buttonStyle(.plain) + .frame(width: 92, height: 92) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + + private var reactionBar: some View { + HStack(spacing: 15) { + Button(action: onTapComment) { + HStack(spacing: SodaSpacing.s4) { + Image("ic_feed_community_reply") + .resizable() + .frame(width: 18, height: 18) + + Text("\(notice.commentCount)") + .appFont(.body2) + .foregroundColor(Color.gray400) + } + } + .buttonStyle(.plain) + + HStack(spacing: SodaSpacing.s4) { + Image(notice.isLiked ? "ic_feed_community_heart_fill" : "ic_feed_community_heart") + .resizable() + .frame(width: 18, height: 18) + + Text("\(notice.likeCount)") + .appFont(.body2) + .foregroundColor(Color.gray400) + } + } + .frame(height: 24) + } +} + +private extension CreatorChannelCommunityPostResponse { + func relativeTimeText(now: Date = Date()) -> String { + DateParser.relativeTimeText(fromUTC: dateUtc, fallback: dateUtc, now: now) + } + + var hasImage: Bool { + !(imageUrl ?? "").isEmpty + } + + var hasAudio: Bool { + !(audioUrl ?? "").isEmpty + } + + var isLocked: Bool { + price > 0 && !existOrdered + } +} + +struct CreatorChannelNoticeSection_Previews: PreviewProvider { + private static let previewImageUrl = "https://picsum.photos/300/300" + + static var previews: some View { + VStack(spacing: SodaSpacing.s20) { + CreatorChannelNoticeSection(notices: []) + + CreatorChannelNoticeSection( + notices: [ + CreatorChannelCommunityPostResponse( + postId: 1, + creatorId: 10, + creatorNickname: "크리에이터 이름", + creatorProfileUrl: previewImageUrl, + imageUrl: previewImageUrl, + audioUrl: nil, + content: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분크리에이터가 커뮤니티에 올린 글이 보이는 부분 말줄임...", + price: 0, + dateUtc: "2026-07-03T03:44:00Z", + existOrdered: true, + likeCount: 5, + commentCount: 2, + isLiked: false + ), + CreatorChannelCommunityPostResponse( + postId: 2, + creatorId: 10, + creatorNickname: "크리에이터 이름", + creatorProfileUrl: previewImageUrl, + imageUrl: nil, + audioUrl: nil, + content: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분", + price: 10, + dateUtc: "2026-07-03T03:42:00Z", + existOrdered: false, + likeCount: 3, + commentCount: 1, + isLiked: false + ) + ] + ) + } + .background(Color.black) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift b/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift index 47c8f5d6..588306ee 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift @@ -38,6 +38,8 @@ struct CreatorChannelHomeView: View { onSelectTab: onSelectTab, onTapDonate: onTapDonate ) + + CreatorChannelNoticeSection(notices: response.notices) } .frame(maxWidth: .infinity, alignment: .leading) .background(Color.black) diff --git a/docs/20260701_크리에이터_채널_홈/plan-task.md b/docs/20260701_크리에이터_채널_홈/plan-task.md index 4f9ec081..a5995f6a 100644 --- a/docs/20260701_크리에이터_채널_홈/plan-task.md +++ b/docs/20260701_크리에이터_채널_홈/plan-task.md @@ -400,7 +400,7 @@ ### Phase 8: 공지 섹션 -- [ ] **Task 8.1: 공지 섹션 구현** +- [x] **Task 8.1: 공지 섹션 구현** - 대상 파일: - 생성: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelNoticeSection.swift` - 수정: `SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift`