From 339ca787931e04f4b0e37c18b61acd1ad54db0c2 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 30 Jun 2026 01:44:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EC=9D=B8=EA=B8=B0=20=EC=BB=A4?= =?UTF-8?q?=EB=AE=A4=EB=8B=88=ED=8B=B0=20=EC=84=B9=EC=85=98=EC=9D=84=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 --- SodaLive.xcodeproj/project.pbxproj | 6 + .../Contents.json | 21 ++ .../ic_feed_community_heart.png | Bin 0 -> 457 bytes .../Contents.json | 21 ++ .../ic_feed_community_heart_fill.png | Bin 0 -> 401 bytes .../Contents.json | 21 ++ .../ic_feed_community_reply.png | Bin 0 -> 447 bytes SodaLive/Sources/Common/DateParser.swift | 32 ++ .../GetCommunityPostListResponse.swift | 33 +-- .../V2/Component/Card/CommunityPostCard.swift | 280 +++++++++++++----- .../MainHomePopularCommunitySection.swift | 101 +++++++ .../MainHomeRecommendationView.swift | 30 ++ .../MainHomeRecommendationViewModel.swift | 77 +++++ .../MainHomeRecommendationResponse.swift | 5 + .../plan-task.md | 44 ++- docs/20260602_메인_홈_추천_UI_API_연동/prd.md | 17 +- 16 files changed, 578 insertions(+), 110 deletions(-) create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart.imageset/ic_feed_community_heart.png create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart_fill.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart_fill.imageset/ic_feed_community_heart_fill.png create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_reply.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_reply.imageset/ic_feed_community_reply.png create mode 100644 SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomePopularCommunitySection.swift diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 5f4c7f59..80444427 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -232,6 +232,8 @@ 401A3FD82FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD52FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift */; }; 401A3FD92FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDB2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift */; }; 401A3FDA2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDB2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift */; }; + 401A3FDD2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDC2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift */; }; + 401A3FDE2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDC2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.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 */; }; @@ -1752,6 +1754,7 @@ 401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeAiCharacterSection.swift; sourceTree = ""; }; 401A3FD52FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeGenreCreatorSection.swift; sourceTree = ""; }; 401A3FDB2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeCheerCreatorSection.swift; sourceTree = ""; }; + 401A3FDC2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomePopularCommunitySection.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 = ""; }; @@ -2758,6 +2761,7 @@ 401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift */, 401A3FD52FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift */, 401A3FDB2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift */, + 401A3FDC2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift */, ); path = Components; sourceTree = ""; @@ -6113,6 +6117,7 @@ 401A3FD22FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */, 401A3FD72FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */, 401A3FD92FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */, + 401A3FDD2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */, 401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */, @@ -6893,6 +6898,7 @@ 401A3FD32FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */, 401A3FD82FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */, 401A3FDA2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */, + 401A3FDE2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */, 401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, ACD71056E97E13E48941ABBB /* MainPlaceholderTabView.swift in Sources */, diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart.imageset/Contents.json new file mode 100644 index 00000000..3156ea39 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ic_feed_community_heart.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart.imageset/ic_feed_community_heart.png b/SodaLive/Resources/Assets.xcassets/v2/ic_feed_community_heart.imageset/ic_feed_community_heart.png new file mode 100644 index 0000000000000000000000000000000000000000..eda0c4b67aabf1b32fee9945261cc62c39c008b2 GIT binary patch literal 457 zcmV;)0XF`LP)*8a9{H!12}&k# zBm_K?PLPhk1{tA@z%wA|cae>d<+SNr`$?AZ-T8OtvoVanCQ~)!dA?_ic{itNUc)ea z7j_T!J$+Bp)Fesrp#^cyUCXjA^E|r%P>_SM748=Htzz%`zTc&3+EKuh1OdWnwrzXk z`~J;YRh5hE7uu||Q(`Q4OGQOdP=+f?-vmL>s>iMC+K~_`0sEADm^iabNIm^$2Evw*1cxRoOY2kiMLh=@6%noQc3 zqs4K2LazvR{tiYyioxemzA=*SL1ws#H)K1k164>AMcLYC>ayx!5`vLZ_39oih=gVy zFx3R3wW!2LC1+jyUCX2qt`s`;c1prjYnwwd zjjg8}w@set;q-$u=Yj5}x%159%O`ObWG%{S%(pfalld8Hy7c1l)$8NkBNV==8GW(y z%XXBBZ!M@&$XtD=Yi-V!Nv>1p-T!gXJmrke?m*Ydb@JC0_EkqPJHFdn(0s{7W_xr& zR+saVyQ)j$ulYY|5@^p_w@61rMmy}A{1Rs6>yop&ZeA)#;u2W;>DkQR8EX_G(~d5_ z_~NstlW;_R<*`qHuZCJybhfa+Y2r~onbr6zCOYxV_xj7p>Kfn6Z`=M8AHh|1)>}_|uoipPCt7q$c7}1`IIoARV{_yMv^Hh$A3Gs34>Q z90B5rJD>y8L6Q#WAbSP@`D+2fW|81x`Aef^XJ?;gb_PNoO~Q1OB#DP*x9XxG6t-=j zW*|V$UDxdpkdt-V!%D|-4kIXvqP-}JXV|l4S>+l^nx;nEw%@Q<*L4e#IgyFM=xZTN zeJt(ok|$nJ9LMh{(c_)L|An_X-uk&9Ns{uqt|{+KmgR!y=YsmQGqOQjN~2X(9em%X zt3kZd0H#ZjMbL)QbVX7AMo`l(ECcF;0pi1c$h`bfGY}KHmf_+XfS;PC p9Zl0*eB?n(T??=TD6J&-);}!ZhpWp`9*+P3002ovPDHLkV1k> String { + guard let date = parse(text) else { + return fallback + } + + let interval = max(0, now.timeIntervalSince(date)) + let calendar = Calendar.current + let yearMonth = calendar.dateComponents([.year, .month], from: date, to: now) + + if let years = yearMonth.year, years >= 1 { + return I18n.Time.yearsAgo(years) + } + + if let months = yearMonth.month, months >= 1 { + return I18n.Time.monthsAgo(months) + } + + if interval < 60 { + return I18n.Time.justNow + } + + if interval < 3600 { + return I18n.Time.minutesAgo(max(1, Int(interval / 60))) + } + + if interval < 86_400 { + return I18n.Time.hoursAgo(max(1, Int(interval / 3600))) + } + + return I18n.Time.daysAgo(max(1, Int(interval / 86_400))) + } // 시도 순서: ISO8601(소수초 포함) → ISO8601 → RFC3339 유사 → 공백 구분 기본 private static let parsers: [(String) -> Date?] = [ diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/GetCommunityPostListResponse.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/GetCommunityPostListResponse.swift index 4a8e5192..32df91e5 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/GetCommunityPostListResponse.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/GetCommunityPostListResponse.swift @@ -34,37 +34,6 @@ extension GetCommunityPostListResponse { /// 상대 시간(방금 전/분/시간/일/개월/년 전)을 반환합니다. /// 파싱 실패 시, `date`를 그대로 반환합니다. func relativeTimeText(now: Date = Date()) -> String { - guard let createdAt = DateParser.parse(dateUtc) else { - return date - } - - let nowDate = now - let interval = max(0, nowDate.timeIntervalSince(createdAt)) - - // 연/월 차이는 달력 기준으로 계산(윤년/월 길이 반영) - let calendar = Calendar.current - let ym = calendar.dateComponents([.year, .month], - from: createdAt, - to: nowDate) - if let years = ym.year, years >= 1 { - return I18n.Time.yearsAgo(years) - } - if let months = ym.month, months >= 1 { - return I18n.Time.monthsAgo(months) - } - - // 일/시간/분은 초 기반으로 간단 처리 - if interval < 60 { - return I18n.Time.justNow - } else if interval < 3600 { - let minutes = Int(interval / 60) - return I18n.Time.minutesAgo(max(1, minutes)) - } else if interval < 86_400 { - let hours = Int(interval / 3600) - return I18n.Time.hoursAgo(max(1, hours)) - } else { - let days = Int(interval / 86_400) - return I18n.Time.daysAgo(max(1, days)) - } + DateParser.relativeTimeText(fromUTC: dateUtc, fallback: date, now: now) } } diff --git a/SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift b/SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift index 11037a97..09658ae5 100644 --- a/SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift +++ b/SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift @@ -1,129 +1,273 @@ import SwiftUI struct CommunityPostCard: View { + let postId: Int let creatorNickname: String let creatorProfileImageUrl: String? let content: String let imageUrl: String? - let price: Int? + let audioUrl: String? + let price: Int let existOrdered: Bool - let createdAt: String? - let likeCount: Int? - let commentCount: Int? + let createdAt: String + let isLike: Bool + let likeCount: Int + let commentCount: Int + let onTapLike: () -> Void + let onTapComment: () -> Void + let onTapPurchase: () -> Void + + @State private var localIsLike: Bool + @State private var localLikeCount: Int + @StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared + @StateObject private var contentPlayManager = ContentPlayManager.shared + + init( + postId: Int, + creatorNickname: String, + creatorProfileImageUrl: String?, + content: String, + imageUrl: String?, + audioUrl: String?, + price: Int, + existOrdered: Bool, + createdAt: String, + isLike: Bool, + likeCount: Int, + commentCount: Int, + onTapLike: @escaping () -> Void = {}, + onTapComment: @escaping () -> Void = {}, + onTapPurchase: @escaping () -> Void = {} + ) { + self.postId = postId + self.creatorNickname = creatorNickname + self.creatorProfileImageUrl = creatorProfileImageUrl + self.content = content + self.imageUrl = imageUrl + self.audioUrl = audioUrl + self.price = price + self.existOrdered = existOrdered + self.createdAt = createdAt + self.isLike = isLike + self.likeCount = likeCount + self.commentCount = commentCount + self.onTapLike = onTapLike + self.onTapComment = onTapComment + self.onTapPurchase = onTapPurchase + _localIsLike = State(initialValue: isLike) + _localLikeCount = State(initialValue: likeCount) + } var body: some View { - VStack(alignment: .leading, spacing: SodaSpacing.s12) { + VStack(alignment: .leading, spacing: SodaSpacing.s16) { header - - Text(content) - .appFont(.body4) - .foregroundColor(.white) - .lineLimit(3) - .truncationMode(.tail) + contentText if hasImage { imageContent } - footer + reactionBar } - .padding(SodaSpacing.s12) + .padding(SodaSpacing.s14) .background(Color.gray900) .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + .onChange(of: isLike) { newValue in + localIsLike = newValue + } + .onChange(of: likeCount) { newValue in + localLikeCount = newValue + } } private var header: some View { HStack(spacing: SodaSpacing.s8) { - DownsampledKFImage(url: URL(string: creatorProfileImageUrl ?? ""), size: CGSize(width: 32, height: 32)) + DownsampledKFImage(url: URL(string: creatorProfileImageUrl ?? ""), size: CGSize(width: 42, height: 42)) .background(Color.gray800) .clipShape(Circle()) + .frame(width: 42, height: 42) - Text(creatorNickname) - .appFont(.body5) - .foregroundColor(.white) - .lineLimit(1) + VStack(alignment: .leading, spacing: 2) { + Text(creatorNickname) + .appFont(.body4) + .foregroundColor(.white) + .lineLimit(1) + + Text(createdAt) + .appFont(.body5) + .foregroundColor(Color.gray500) + .lineLimit(1) + } Spacer(minLength: 0) } } - private var imageContent: some View { - ZStack { - DownsampledKFImage(url: URL(string: imageUrl ?? ""), size: CGSize(width: 240, height: 150)) - .background(Color.gray800) - .frame(maxWidth: .infinity) - .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s12, style: .continuous)) - .blur(radius: isLocked ? 8 : 0) + private var contentText: some View { + Text(content) + .appFont(.body2) + .foregroundColor(.white) + .lineLimit(5) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + } - if isLocked { - VStack(spacing: SodaSpacing.s6) { + private var imageContent: some View { + GeometryReader { proxy in + ZStack { + DownsampledKFImage( + url: URL(string: imageUrl ?? ""), + size: CGSize(width: proxy.size.width, height: proxy.size.height) + ) + .background(Color.gray800) + .frame(width: proxy.size.width, height: proxy.size.height) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + .blur(radius: isLocked ? 12 : 0) + + if isLocked { + paidLockOverlay + } else if hasAudio { + audioPlayButton + } + } + } + .aspectRatio(346.0 / 236.0, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + + 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) - if let price { - Text("\(price) can") - .appFont(.caption2) - .foregroundColor(Color.button) - .padding(.horizontal, SodaSpacing.s12) - .padding(.vertical, SodaSpacing.s6) - .overlay( - Capsule() - .stroke(Color.button, lineWidth: 1) - ) + HStack(spacing: SodaSpacing.s6) { + Image("ic_can") + .resizable() + .frame(width: 20, height: 20) + + Text("\(price)") + .appFont(.body2) + .foregroundColor(.black) } + .padding(SodaSpacing.s8) + .background(Color.white) + .clipShape(Capsule()) } } } + .buttonStyle(.plain) } - private var footer: some View { - HStack(spacing: SodaSpacing.s12) { - if let createdAt, !createdAt.isEmpty { - Text(createdAt) - .appFont(.caption2) - .foregroundColor(Color.gray500) - } + private var audioPlayButton: some View { + Button { + guard let audioUrl, !audioUrl.isEmpty else { return } - if let likeCount { - Text("Like \(likeCount)") - .appFont(.caption2) - .foregroundColor(Color.gray500) - } - - if let commentCount { - Text("Comment \(commentCount)") - .appFont(.caption2) - .foregroundColor(Color.gray500) - } + contentPlayManager.pauseAudio() + playManager.toggleContent(item: CreatorCommunityContentItem(contentId: postId, url: audioUrl)) + } label: { + Image(playManager.isPlaying && playManager.currentPlayingContentId == postId ? "btn_audio_content_pause" : "btn_audio_content_play") + .resizable() + .frame(width: 48, height: 48) } + .buttonStyle(.plain) + } + + private var reactionBar: some View { + HStack(spacing: 15) { + Button { + toggleLike() + } label: { + HStack(spacing: SodaSpacing.s4) { + Image(localIsLike ? "ic_feed_community_heart_fill" : "ic_feed_community_heart") + .resizable() + .frame(width: 18, height: 18) + + Text("\(localLikeCount)") + .appFont(.body2) + .foregroundColor(Color.gray400) + } + } + .buttonStyle(.plain) + + Button(action: onTapComment) { + HStack(spacing: SodaSpacing.s4) { + Image("ic_feed_community_reply") + .resizable() + .frame(width: 18, height: 18) + + Text("\(commentCount)") + .appFont(.body2) + .foregroundColor(Color.gray400) + } + } + .buttonStyle(.plain) + } + .frame(height: 24) } private var hasImage: Bool { !(imageUrl ?? "").isEmpty } + private var hasAudio: Bool { + !(audioUrl ?? "").isEmpty + } + private var isLocked: Bool { - (price ?? 0) > 0 && !existOrdered + price > 0 && !existOrdered + } + + private func toggleLike() { + localIsLike.toggle() + localLikeCount += localIsLike ? 1 : -1 + localLikeCount = max(0, localLikeCount) + onTapLike() } } struct CommunityPostCard_Previews: PreviewProvider { + private static let previewImageUrl = "https://picsum.photos/500/500" + static var previews: some View { - CommunityPostCard( - creatorNickname: "크리에이터", - creatorProfileImageUrl: nil, - content: "커뮤니티 본문입니다.", - imageUrl: nil, - price: nil, - existOrdered: false, - createdAt: "방금 전", - likeCount: 10, - commentCount: 2 - ) - .padding(SodaSpacing.s20) + VStack(spacing: SodaSpacing.s8) { + CommunityPostCard( + postId: 1, + creatorNickname: "크리에이터 이름", + creatorProfileImageUrl: previewImageUrl, + content: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분", + imageUrl: nil, + audioUrl: nil, + price: 0, + existOrdered: false, + createdAt: "2분 전", + isLike: false, + likeCount: 5, + commentCount: 6 + ) + + CommunityPostCard( + postId: 2, + creatorNickname: "크리에이터 이름", + creatorProfileImageUrl: previewImageUrl, + content: "이미지가 있는 커뮤니티 게시글입니다.", + imageUrl: previewImageUrl, + audioUrl: "https://example.com/audio.mp3", + price: 30, + existOrdered: false, + createdAt: "2분 전", + isLike: true, + likeCount: 5, + commentCount: 6 + ) + } + .padding(SodaSpacing.s14) .background(Color.black) .previewLayout(.sizeThatFits) } diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomePopularCommunitySection.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomePopularCommunitySection.swift new file mode 100644 index 00000000..d5e865af --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomePopularCommunitySection.swift @@ -0,0 +1,101 @@ +import SwiftUI + +struct MainHomePopularCommunitySection: View { + let items: [HomePopularCommunityPostItem] + let onTapCommunity: (Int) -> Void + let onTapLike: (Int) -> Void + let onTapPurchase: (HomePopularCommunityPostItem) -> Void + + init( + items: [HomePopularCommunityPostItem], + onTapCommunity: @escaping (Int) -> Void = { _ in }, + onTapLike: @escaping (Int) -> Void = { _ in }, + onTapPurchase: @escaping (HomePopularCommunityPostItem) -> Void = { _ in } + ) { + self.items = items + self.onTapCommunity = onTapCommunity + self.onTapLike = onTapLike + self.onTapPurchase = onTapPurchase + } + + var body: some View { + if !items.isEmpty { + VStack(alignment: .leading, spacing: SodaSpacing.s14) { + SectionTitle(title: I18n.HomeRecommendation.popularCommunitySectionTitle) + + VStack(alignment: .leading, spacing: SodaSpacing.s8) { + ForEach(items, id: \.postId) { item in + CommunityPostCard( + postId: item.postId, + creatorNickname: item.creatorNickname, + creatorProfileImageUrl: item.creatorProfileImage, + content: item.content, + imageUrl: item.imageUrl, + audioUrl: item.audioUrl, + price: item.price, + existOrdered: item.existOrdered, + createdAt: item.relativeCreatedAtText(), + isLike: item.isLiked, + likeCount: item.likeCount, + commentCount: item.commentCount, + onTapLike: { + onTapLike(item.postId) + }, + onTapComment: { + onTapCommunity(item.creatorId) + }, + onTapPurchase: { + onTapPurchase(item) + } + ) + } + } + .padding(.horizontal, SodaSpacing.s14) + } + } + } +} + +struct MainHomePopularCommunitySection_Previews: PreviewProvider { + private static let previewImageUrl = "https://picsum.photos/500/500" + + static var previews: some View { + MainHomePopularCommunitySection( + items: [ + HomePopularCommunityPostItem( + postId: 1, + creatorId: 10, + creatorNickname: "크리에이터 이름", + creatorProfileImage: previewImageUrl, + content: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분", + imageUrl: nil, + audioUrl: nil, + price: 0, + existOrdered: false, + likeCount: 5, + commentCount: 6, + createdAt: "2026-06-29T12:00:00Z", + isLiked: false + ), + HomePopularCommunityPostItem( + postId: 2, + creatorId: 11, + creatorNickname: "크리에이터 이름", + creatorProfileImage: previewImageUrl, + content: "이미지가 있는 커뮤니티 게시글입니다.", + imageUrl: previewImageUrl, + audioUrl: "https://example.com/audio.mp3", + price: 30, + existOrdered: false, + likeCount: 5, + commentCount: 6, + createdAt: "2026-06-29T12:00:00Z", + isLiked: true + ) + ] + ) + .padding(.vertical, SodaSpacing.s20) + .background(Color.black) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift index a83c5c5c..35d676bc 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift @@ -10,6 +10,7 @@ struct MainHomeRecommendationView: View { let onTapFollowAll: (@escaping () -> Void) -> Void @StateObject private var viewModel = MainHomeRecommendationViewModel() + @StateObject private var communityMediaPlayer = CreatorCommunityMediaPlayerManager.shared var body: some View { ZStack { @@ -75,6 +76,17 @@ struct MainHomeRecommendationView: View { } } ) + + MainHomePopularCommunitySection( + items: viewModel.recommendations?.popularCommunityPosts ?? [], + onTapCommunity: onTapCommunity, + onTapLike: { postId in + viewModel.likeCommunityPost(postId: postId) + }, + onTapPurchase: { item in + viewModel.openCommunityPostPurchase(postId: item.postId, price: item.price) + } + ) } .padding(.vertical, SodaSpacing.s20) } @@ -90,6 +102,24 @@ struct MainHomeRecommendationView: View { message: viewModel.errorMessage, autohideIn: 2 ) + .sodaToast( + isPresented: $communityMediaPlayer.isShowPopup, + message: communityMediaPlayer.errorMessage, + autohideIn: 2 + ) + .overlay { + if viewModel.isShowCommunityPostPurchaseDialog { + CommunityPostPurchaseDialog( + isShowing: $viewModel.isShowCommunityPostPurchaseDialog, + can: viewModel.purchasingCommunityPostPrice, + confirmAction: viewModel.purchaseCommunityPost + ) + } + + if communityMediaPlayer.isLoading { + LoadingView() + } + } } } diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift index d6340b31..e8a77a3d 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift @@ -3,14 +3,18 @@ import Combine final class MainHomeRecommendationViewModel: ObservableObject { private let repository = MainHomeRecommendationRepository() + private let communityRepository = CreatorCommunityRepository() private var subscription = Set() @Published var isLoading = false @Published var isShowPopup = false @Published var errorMessage = "" @Published var recommendations: HomeRecommendationResponse? + @Published var isShowCommunityPostPurchaseDialog = false + @Published var purchasingCommunityPostPrice = 0 @Published private(set) var completedFollowKeys = Set() @Published private(set) var followingKeys = Set() + private var purchasingCommunityPostId = 0 func fetchRecommendations() { isLoading = true @@ -104,4 +108,77 @@ final class MainHomeRecommendationViewModel: ObservableObject { func isFollowAllLoading(_ completionKey: String) -> Bool { followingKeys.contains(completionKey) } + + func likeCommunityPost(postId: Int) { + guard postId > 0 else { return } + + communityRepository.communityPostLike(postId: postId) + .sink { [weak self] result in + guard let self else { return } + + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } receiveValue: { _ in } + .store(in: &subscription) + } + + func openCommunityPostPurchase(postId: Int, price: Int) { + guard postId > 0, price > 0 else { return } + + purchasingCommunityPostId = postId + purchasingCommunityPostPrice = price + isShowCommunityPostPurchaseDialog = true + } + + func purchaseCommunityPost() { + let postId = purchasingCommunityPostId + guard postId > 0, !isLoading else { return } + + isLoading = true + + communityRepository.purchaseCommunityPost(postId: postId) + .sink { [weak self] result in + guard let self else { return } + + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + self.isLoading = false + } + } receiveValue: { [weak self] response in + guard let self else { return } + + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if decoded.success { + self.purchasingCommunityPostId = 0 + self.purchasingCommunityPostPrice = 0 + self.fetchRecommendations() + } else { + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + self.isLoading = false + } + } catch { + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + self.isLoading = false + } + } + .store(in: &subscription) + } } diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift index 15920f74..fe0afe4a 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift @@ -79,4 +79,9 @@ struct HomePopularCommunityPostItem: Decodable, Hashable { let likeCount: Int let commentCount: Int let createdAt: String + let isLiked: Bool + + func relativeCreatedAtText(now: Date = Date()) -> String { + DateParser.relativeTimeText(fromUTC: createdAt, fallback: createdAt, now: now) + } } diff --git a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md index 3a893a14..6bd7eb87 100644 --- a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md +++ b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md @@ -381,7 +381,7 @@ ### Phase 15: 인기 커뮤니티 섹션 UI와 FeedCommunityView -- [ ] **Task 15.1: FeedCommunityView/CommunityPostCard 요구 보완** +- [x] **Task 15.1: FeedCommunityView/CommunityPostCard 요구 보완** - 대상 파일: - 확인/보완: `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` - 생성/보완: `SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomePopularCommunitySection.swift` @@ -392,10 +392,12 @@ - `price <= 0 || existOrdered == true`이면 일반 이미지로 표시한다. - `구매완료` 버튼은 표시하지 않는다. - `creatorProfileImage`, `createdAt`, `likeCount`, `commentCount`를 표시한다. - - `audioUrl` 재생 UI는 확정 전까지 추가하지 않는다. + - `audioUrl`이 있으면 이미지 중앙 재생 UI를 표시한다. + - 기존 커뮤니티 구매 API를 재사용해 유료 미구매 포스트 구매를 지원한다. + - `createdAt`은 UTC로 파싱해 디바이스 Timezone 기준 상대 날짜로 표시한다. - 검증 기준: - - 실행 명령: `rg "MainHomePopularCommunitySection|CommunityPostCard|imageUrl|audioUrl|existOrdered|구매완료" SodaLive/Sources/V2` - - 기대 결과: 커뮤니티 섹션과 카드 조건 분기가 확인되고 `구매완료` 표시 구현은 없어야 한다. + - 실행 명령: `rg "MainHomePopularCommunitySection|CommunityPostCard|imageUrl|audioUrl|existOrdered|구매완료|purchaseCommunityPost|communityPostLike|toggleContent|relativeTimeText" SodaLive/Sources/V2 SodaLive/Sources/Common SodaLive/Sources/Explorer/Profile/CreatorCommunity` + - 기대 결과: 커뮤니티 섹션과 카드 조건 분기, 오디오 재생, 구매/좋아요 연결, UTC 상대 날짜 변환이 확인되고 `구매완료` 표시 구현은 없어야 한다. ### Phase 16: 사업자 정보 섹션 UI @@ -477,6 +479,40 @@ ## 검증 기록 +### 2026-06-30 Phase 15 isLiked 컴파일 보완 + +- 목적: `HomePopularCommunityPostItem.isLiked` 추가 이후 추천 인기 커뮤니티 섹션 컴파일 오류 보완 +- 수행 내용: + - `HomePopularCommunityPostItem`에 `isLiked` 필드를 유지하고, 섹션에서 사용하는 `relativeCreatedAtText(now:)` helper를 복구 + - `MainHomePopularCommunitySection`이 `item.isLiked`를 `CommunityPostCard(isLike:)` 초기 상태로 전달하는지 확인 +- 검증: + - `rg -n "HomePopularCommunityPostItem\(|\.isLike\b|isLike: item|isLiked|relativeCreatedAtText"` 실행, 추천 섹션에 남은 `item.isLike` 참조 없음 확인 + - `git diff --check` 실행, 출력 없이 성공 확인 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인 + +### 2026-06-29 Phase 15 인기 커뮤니티 섹션 세로 FeedCommunityView 보정 완료 + +- 목적: V2 Main Home 추천 탭의 인기 커뮤니티 섹션을 Figma `24:5645`, `567:17875`, `567:17878` 기준 세로 FeedCommunityView 형태로 보정 +- 수행 내용: + - `MainHomePopularCommunitySection`의 가로 `ScrollView`/`LazyHStack`/240폭 카드 구성을 제거하고, 부모 추천 ScrollView 안에서 세로 `VStack` 피드 목록으로 표시 + - `CommunityPostCard`를 Figma FeedCommunityView 형태로 보정해 Text Only, Text + Image, Text + Image + Audio, 유료 미구매 잠금 상태를 지원 + - `audioUrl`이 있으면 기존 `CreatorCommunityMediaPlayerManager`와 `ContentPlayManager.pauseAudio()` 흐름을 재사용해 이미지 중앙 재생/일시정지 버튼을 표시 + - 기존 `CreatorCommunityRepository.communityPostLike(postId:)`, `purchaseCommunityPost(postId:)`, `CommunityPostPurchaseDialog`를 추천 ViewModel에 연결해 좋아요와 유료 미구매 포스트 구매를 지원 + - `DateParser.relativeTimeText(fromUTC:fallback:now:)`를 공용 함수로 추가하고, 기존 커뮤니티 상세와 추천 홈 카드가 UTC 날짜를 디바이스 Timezone 기준 상대 날짜로 표시하도록 연결 + - `HomePopularCommunityPostItem.isLiked`를 카드 좋아요 초기 상태와 연결 + - 키워드 영역과 `구매완료` 버튼은 추가하지 않음 + - `MainHomeRecommendationView`에서 응원 섹션 다음에 인기 커뮤니티 섹션을 배치하고, 댓글 아이콘 탭은 기존 `creatorCommunityAll(creatorId:)` 라우팅에 맞춰 `creatorId`를 전달 + - 신규 `MainHomePopularCommunitySection.swift`를 `SodaLive.xcodeproj/project.pbxproj` 빌드 대상에 포함 +- 검증: + - Figma `24:5645`, `567:17875`, `567:17878` 디자인 컨텍스트와 스크린샷 확인, 인기 커뮤니티 섹션이 세로 리스트이고 Feed 카드가 14 padding, 16 gap, 14 radius, 댓글/좋아요 18 아이콘 구조임을 확인 + - `rg -n "ScrollView\\(\\.horizontal|LazyHStack|frame\\(width: 240\\)" SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomePopularCommunitySection.swift SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` 실행, 검색 결과 없음 확인 + - `rg -n "ic_feed_community_heart|ic_feed_community_heart_fill|ic_feed_community_reply|CommunityPostPurchaseDialog|purchaseCommunityPost|communityPostLike|toggleContent\\(item:|relativeTimeText\\(fromUTC|relativeCreatedAtText|CreatorCommunityRepository" SodaLive/Sources` 실행, 좋아요/댓글 아이콘, 구매/좋아요 API, 오디오 재생, UTC 상대 날짜 연결 확인 + - `rg -n "localhost:3845|figma.com" SodaLive/Sources/V2/Main/Home SodaLive/Sources/V2/Component` 실행, 검색 결과 없음 확인 + - `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행, `OK` 확인 + - `git diff --check` 실행, 출력 없이 성공 확인 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` 실행, `Scheme SodaLive-dev is not currently configured for the test action`으로 테스트 액션 미구성 확인 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인 + ### 2026-06-29 Phase 14 최근 응원이 많은 크리에이터 섹션 구현 완료 - 목적: V2 Main Home 추천 탭에 Figma `24:5636`, `309:19196` 기준 최근 응원이 많은 크리에이터 섹션을 연결 diff --git a/docs/20260602_메인_홈_추천_UI_API_연동/prd.md b/docs/20260602_메인_홈_추천_UI_API_연동/prd.md index f3cabd69..9ac7e2e3 100644 --- a/docs/20260602_메인_홈_추천_UI_API_연동/prd.md +++ b/docs/20260602_메인_홈_추천_UI_API_연동/prd.md @@ -9,7 +9,7 @@ - 추천 탭 전용 데이터가 기존 홈 API와 섞이면 응답/화면 책임이 커지고 회귀 위험이 커진다. - Figma 추천 탭은 라이브, 배너, 최근 활동, 최근 데뷔, AI 캐릭터, 장르 크리에이터, 응원 크리에이터, 인기 커뮤니티, 사업자 정보 등 여러 독립 섹션으로 구성된다. - 섹션별 UI가 많으므로 한 화면 파일에 직접 구현하면 유지보수와 검증이 어려워진다. -- `FeedCommunityView`는 추천 탭 요구에 맞춰 키워드 영역 제거, 이미지 표시, 유료/구매 상태 UI가 필요하다. +- `FeedCommunityView`는 추천 탭 요구에 맞춰 키워드 영역 제거, 이미지 표시, 오디오 재생, 유료/구매 상태 UI가 필요하다. - 사업자 정보는 외부 라이브러리 없이 최대 3줄 말줄임표, 더보기, 접기 동작을 제공해야 한다. ## 3. Goals @@ -22,7 +22,7 @@ - `RecommendedActivityType` 서버 enum 값을 앱 enum으로 변환하고 I18n 문구로 표시한다. - 빈 데이터 섹션은 제목과 컨테이너를 표시하지 않는다. - 사업자 정보 섹션은 화면 width를 채우고, 기본 3줄 말줄임표와 더보기/접기 전환을 지원한다. -- `FeedCommunityView`는 추천 탭 요구에 맞게 이미지와 유료 커뮤니티 포스트 UI를 지원한다. +- `FeedCommunityView`는 추천 탭 요구에 맞게 세로 피드형 이미지/오디오/유료 커뮤니티 포스트 UI를 지원한다. ## 4. Non-Goals - `추천 필모그래피` 섹션은 만들지 않는다. @@ -135,6 +135,7 @@ data class HomePopularCommunityPostItem( val createdAt: String, val likeCount: Long, val commentCount: Long, + val isLiked: Boolean, val existOrdered: Boolean ) ``` @@ -187,18 +188,22 @@ enum class RecommendedActivityType(val code: String) { ### 7.4 FeedCommunityView 참조 Figma: -- 유료이고 구매하지 않은 UI: `node-id=309-19774` -- 유료인데 구매함 또는 무료 UI: `node-id=309-19775` +- 인기 커뮤니티 섹션: `node-id=24-5645` +- Text Only Feed: `node-id=567-17875` +- Text + Image Feed: `node-id=567-17878` 요구사항: - 키워드 영역을 제거한다. +- 인기 커뮤니티 섹션은 가로 캐러셀이 아니라 세로 피드 목록으로 배치한다. - 커뮤니티 포스트 이미지를 추가한다. - `imageUrl == nil`이면 이미지 영역 없이 본문 중심으로 표시한다. - `imageUrl != nil && price > 0 && existOrdered == false`이면 유료 미구매 상태 UI를 표시한다. - `imageUrl != nil && (price <= 0 || existOrdered == true)`이면 이미지를 일반 노출한다. -- `node-id=309-19775`의 `구매완료` 버튼은 뺀다. +- `audioUrl`이 있으면 이미지 중앙에 재생/일시정지 버튼을 표시하고 기존 커뮤니티 오디오 재생 흐름을 재사용한다. +- 유료 미구매 커뮤니티 포스트는 기존 커뮤니티 구매 API로 구매 가능해야 한다. +- `구매완료` 버튼은 표시하지 않는다. - 작성자, 본문, 생성 시간, 좋아요 수, 댓글 수를 표시한다. -- `audioUrl`은 이번 추천 탭 카드에서 표시 대상이 확정되지 않았으므로 재생 UI를 추가하지 않는다. +- 생성 시간은 UTC 값을 디바이스 Timezone 기준 상대 날짜로 표시한다. ### 7.5 사업자 정보 더보기/접기