diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 29954f96..00347972 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -1875,6 +1875,36 @@ CC0530082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0500082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift */; }; CC0530092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0500092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift */; }; CC05300A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC05000A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift */; }; + CC0610012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */; }; + CC0610022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift */; }; + CC0610032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift */; }; + CC0610042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */; }; + CC0610052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */; }; + CC0610062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift */; }; + CC0610072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift */; }; + CC0610082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */; }; + CC0610092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */; }; + CC06100A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */; }; + CC0620012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */; }; + CC0620022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift */; }; + CC0620032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift */; }; + CC0620042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */; }; + CC0620052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */; }; + CC0620062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift */; }; + CC0620072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift */; }; + CC0620082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */; }; + CC0620092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */; }; + CC06200A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */; }; + CC0630012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */; }; + CC0630022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift */; }; + CC0630032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift */; }; + CC0630042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */; }; + CC0630052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */; }; + CC0630062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift */; }; + CC0630072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift */; }; + CC0630082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */; }; + CC0630092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */; }; + CC06300A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */; }; CD256952CC366FFB67557807 /* CreatorChannelFanTalkApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 002435A7145D59CCCA8F76D1 /* CreatorChannelFanTalkApi.swift */; }; CE935959F7A2BF7301F517DB /* CreatorChannelFanTalkTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1C1B0C73E8D92112E47610 /* CreatorChannelFanTalkTabResponse.swift */; }; CFC28133D4D60FD1E436D97C /* CreatorChannelFanTalkTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7923ABAD25E9CC4840EC94 /* CreatorChannelFanTalkTabView.swift */; }; @@ -2779,6 +2809,16 @@ CC0500082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationEmptyView.swift; sourceTree = ""; }; CC0500092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationFloatingButton.swift; sourceTree = ""; }; CC05000A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationCard.swift; sourceTree = ""; }; + CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityTabResponse.swift; sourceTree = ""; }; + CC0600022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityApi.swift; sourceTree = ""; }; + CC0600032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityRepository.swift; sourceTree = ""; }; + CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityViewModel.swift; sourceTree = ""; }; + CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityTabView.swift; sourceTree = ""; }; + CC0600062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityViewModeBar.swift; sourceTree = ""; }; + CC0600072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityListItem.swift; sourceTree = ""; }; + CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityGridItem.swift; sourceTree = ""; }; + CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityUploadButton.swift; sourceTree = ""; }; + CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityEmptyView.swift; sourceTree = ""; }; CC73FFC54CB1D988053059D1 /* LanguageEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageEnvironment.swift; sourceTree = ""; }; CE82912F2F111593BE42E33C /* CommunityPostCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CommunityPostCard.swift; sourceTree = ""; }; D03CBA8005C81F7C31F59A89 /* Pods-SodaLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SodaLive.release.xcconfig"; path = "Target Support Files/Pods-SodaLive/Pods-SodaLive.release.xcconfig"; sourceTree = ""; }; @@ -5430,6 +5470,7 @@ CC0300112FF0300000A5BBA3 /* Audio */, CC0400112FF0400000A5BBA3 /* Series */, CC05001B2FF0500000A5BBA3 /* Donation */, + CC06000E2FF0600000A5BBA3 /* Community */, CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */, CC0100092FF0100000A5BBA3 /* CreatorChannelViewModel.swift */, AA4588B91C0F73344C3DC4A4 /* FanTalk */, @@ -5615,6 +5656,47 @@ path = Donation; sourceTree = ""; }; + CC06000B2FF0600000A5BBA3 /* Models */ = { + isa = PBXGroup; + children = ( + CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; + CC06000C2FF0600000A5BBA3 /* Repository */ = { + isa = PBXGroup; + children = ( + CC0600022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift */, + CC0600032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift */, + ); + path = Repository; + sourceTree = ""; + }; + CC06000D2FF0600000A5BBA3 /* Components */ = { + isa = PBXGroup; + children = ( + CC0600062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift */, + CC0600072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift */, + CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */, + CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */, + CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */, + ); + path = Components; + sourceTree = ""; + }; + CC06000E2FF0600000A5BBA3 /* Community */ = { + isa = PBXGroup; + children = ( + CC06000B2FF0600000A5BBA3 /* Models */, + CC06000C2FF0600000A5BBA3 /* Repository */, + CC06000D2FF0600000A5BBA3 /* Components */, + CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */, + CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */, + ); + path = Community; + sourceTree = ""; + }; D37878F5929361B43B1E3C5C /* Card */ = { isa = PBXGroup; children = ( @@ -6099,6 +6181,16 @@ CC0510082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */, CC0510092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */, CC05100A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift in Sources */, + CC0610012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift in Sources */, + CC0610022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift in Sources */, + CC0610032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift in Sources */, + CC0610042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift in Sources */, + CC0610052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift in Sources */, + CC0610062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift in Sources */, + CC0610072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift in Sources */, + CC0610082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */, + CC0610092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */, + CC06100A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */, 409E35082B0759DE00CCB985 /* MoyaProviderExtension.swift in Sources */, 40DDBD772B2B4FD700A2E8DA /* CreatorCommunityMoreItemView.swift in Sources */, 400EA7742A89C42C004992CF /* StartLiveRequest.swift in Sources */, @@ -6945,6 +7037,16 @@ CC0520082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */, CC0520092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */, CC05200A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift in Sources */, + CC0620012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift in Sources */, + CC0620022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift in Sources */, + CC0620032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift in Sources */, + CC0620042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift in Sources */, + CC0620052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift in Sources */, + CC0620062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift in Sources */, + CC0620072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift in Sources */, + CC0620082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */, + CC0620092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */, + CC06200A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */, 401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, 401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, @@ -7817,6 +7919,16 @@ CC0530082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */, CC0530092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */, CC05300A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift in Sources */, + CC0630012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift in Sources */, + CC0630022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift in Sources */, + CC0630032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift in Sources */, + CC0630042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift in Sources */, + CC0630052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift in Sources */, + CC0630062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift in Sources */, + CC0630072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift in Sources */, + CC0630082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */, + CC0630092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */, + CC06300A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */, 401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, 401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_new_grid.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_new_grid.imageset/Contents.json new file mode 100644 index 00000000..513c5345 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_new_grid.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ic_new_grid.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_new_grid.imageset/ic_new_grid.png b/SodaLive/Resources/Assets.xcassets/v2/ic_new_grid.imageset/ic_new_grid.png new file mode 100644 index 00000000..260dd23e Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/v2/ic_new_grid.imageset/ic_new_grid.png differ diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_new_list.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_new_list.imageset/Contents.json new file mode 100644 index 00000000..e5bef6e9 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_new_list.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ic_new_list.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_new_list.imageset/ic_new_list.png b/SodaLive/Resources/Assets.xcassets/v2/ic_new_list.imageset/ic_new_list.png new file mode 100644 index 00000000..b3d03a8c Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/v2/ic_new_list.imageset/ic_new_list.png differ diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/Report/CreatorCommunityReportView.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/Report/CreatorCommunityReportView.swift index 0306d6e8..8d1f562e 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/Report/CreatorCommunityReportView.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/Report/CreatorCommunityReportView.swift @@ -52,14 +52,14 @@ struct CreatorCommunityReportView: View { Text(I18n.Common.cancel) .appFont(size: 13.3, weight: .medium) - .foregroundColor(Color(hex: "9970ff")) + .foregroundColor(Color.soda400) .onTapGesture { isShowing = false } Text(I18n.Report.reportAction) .appFont(size: 13.3, weight: .medium) - .foregroundColor(Color(hex: "9970ff")) + .foregroundColor(Color.soda400) .onTapGesture { if let selectedIndex = selectedIndex { confirmAction(reasons[selectedIndex]) diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index fe0249d0..52e02bbf 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -3276,6 +3276,19 @@ If you block this user, the following features will be restricted. } } + enum CreatorChannelCommunity { + static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") } + static var listView: String { pick(ko: "리스트형", en: "List", ja: "リスト型") } + static var thumbnailView: String { pick(ko: "썸네일형", en: "Thumbnail", ja: "サムネイル型") } + static var communityEmpty: String { + pick( + ko: "크리에이터가 커뮤니티를 준비 중입니다.\n기대해 주세요!", + en: "The creator is preparing the community.\nPlease look forward to it!", + ja: "クリエイターがコミュニティを準備中です。\nお楽しみに!" + ) + } + } + enum HomeRecommendation { static var activityLive: String { pick(ko: "라이브", en: "Live", ja: "ライブ") diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityEmptyView.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityEmptyView.swift new file mode 100644 index 00000000..359d23d1 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityEmptyView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct CreatorChannelCommunityEmptyView: View { + var body: some View { + VStack(spacing: 0) { + Text(I18n.CreatorChannelCommunity.communityEmpty) + .appFont(size: 16, weight: .regular) + .foregroundColor(Color.gray500) + .multilineTextAlignment(.center) + .lineSpacing(SodaSpacing.s4) + } + .frame(maxWidth: .infinity) + .padding(.top, 316) + .padding(.bottom, 120) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift new file mode 100644 index 00000000..b02016ec --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct CreatorChannelCommunityGridItem: View { + let post: CreatorChannelCommunityPostItem + let isOwnPost: Bool + let onTapPurchase: () -> Void + + var body: some View { + GeometryReader { proxy in + ZStack { + if isPaidLocked { + Button(action: onTapPurchase) { + Color.gray400 + .overlay(paidPriceOverlay) + } + .buttonStyle(.plain) + } else if let imageUrl = post.imageUrl, !imageUrl.isEmpty { + DownsampledKFImage(url: URL(string: imageUrl), size: CGSize(width: proxy.size.width, height: proxy.size.width)) + .frame(width: proxy.size.width, height: proxy.size.width) + .aspectRatio(1, contentMode: .fill) + .clipped() + } else { + Color.gray900 + + Text(fallbackText) + .appFont(size: 12, weight: .medium) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .lineLimit(3) + .padding(SodaSpacing.s8) + } + } + .frame(width: proxy.size.width, height: proxy.size.width) + .clipped() + .overlay(alignment: .topTrailing) { + if post.isPinned { + Image("ic_pin") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .padding(6) + .shadow(color: .black.opacity(0.45), radius: 2, x: 0, y: 1) + .allowsHitTesting(false) + } + } + } + .aspectRatio(1, contentMode: .fit) + } + + private var paidPriceOverlay: some View { + 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("\(post.price)") + .appFont(size: 16, weight: .medium) + .foregroundColor(.black) + } + .padding(SodaSpacing.s8) + .background(Color.white) + .clipShape(Capsule()) + } + } + + private var isPaidLocked: Bool { + post.price > 0 && post.existOrdered == false && isOwnPost == false + } + + private var fallbackText: String { + let content = post.content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty else { return "-" } + return String(content.prefix(18)) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift new file mode 100644 index 00000000..0b6e59c2 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift @@ -0,0 +1,262 @@ +import SwiftUI + +import Kingfisher +import SDWebImageSwiftUI + +struct CreatorChannelCommunityListItem: View { + let post: CreatorChannelCommunityPostItem + let isOwnPost: Bool + let isOwnCreatorChannel: Bool + let onTapLike: () -> Void + let onTapComment: () -> Void + let onTapMore: () -> 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( + post: CreatorChannelCommunityPostItem, + isOwnPost: Bool, + isOwnCreatorChannel: Bool, + onTapLike: @escaping () -> Void, + onTapComment: @escaping () -> Void, + onTapMore: @escaping () -> Void, + onTapPurchase: @escaping () -> Void + ) { + self.post = post + self.isOwnPost = isOwnPost + self.isOwnCreatorChannel = isOwnCreatorChannel + self.onTapLike = onTapLike + self.onTapComment = onTapComment + self.onTapMore = onTapMore + self.onTapPurchase = onTapPurchase + _localIsLike = State(initialValue: post.isLiked ?? false) + _localLikeCount = State(initialValue: post.likeCount) + } + + var body: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s16) { + if post.isPinned { + pinnedLabel + } + + header + contentText + + if isPaidLocked { + paidLockedImage + } else if let imageUrl = post.imageUrl, !imageUrl.isEmpty { + imageContent(imageUrl: imageUrl) + } + + reactionBar + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(SodaSpacing.s14) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + .onChange(of: post.isLiked) { newValue in + localIsLike = newValue ?? false + } + .onChange(of: post.likeCount) { newValue in + localLikeCount = newValue + } + } + + private var pinnedLabel: some View { + HStack(spacing: 2) { + Image("ic_pin") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + + Text("Notice") + .appFont(size: 14, weight: .medium) + .foregroundColor(Color(hex: "73FF01")) + } + } + + private var header: some View { + HStack(spacing: SodaSpacing.s8) { + DownsampledKFImage(url: URL(string: post.creatorProfileUrl), size: CGSize(width: 42, height: 42)) + .background(Color.gray800) + .clipShape(Circle()) + .frame(width: 42, height: 42) + + VStack(alignment: .leading, spacing: 2) { + Text(post.creatorNickname) + .appFont(size: 14, weight: .medium) + .foregroundColor(.white) + .lineLimit(1) + + Text(post.relativeTimeText()) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray500) + .lineLimit(1) + } + + Spacer(minLength: 0) + + if shouldShowOwnerControls { + HStack(spacing: SodaSpacing.s4) { + if post.price > 0 { + priceTag + } + + moreButton + } + } else if !isOwnPost { + moreButton + } + } + } + + private var priceTag: some View { + HStack(spacing: 2) { + Image("ic_bar_cash") + .resizable() + .frame(width: 18, height: 18) + + Text("\(post.price)") + .appFont(size: 12, weight: .regular) + .foregroundColor(.black) + } + .padding(.horizontal, SodaSpacing.s4) + .padding(.vertical, 2) + .background(Color.white) + .clipShape(Capsule()) + } + + private var moreButton: some View { + Button(action: onTapMore) { + Image("ic_seemore_vertical") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + + private var contentText: some View { + Text(post.content) + .appFont(size: 16, weight: .regular) + .foregroundColor(.white) + .lineLimit(5) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func imageContent(imageUrl: String) -> some View { + ZStack { + WebImage(url: URL(string: imageUrl)) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .background(Color.gray800) + + if let audioUrl = post.audioUrl, !audioUrl.isEmpty { + audioPlayButton(audioUrl: audioUrl) + } + } + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + + private var paidLockedImage: some View { + Button(action: onTapPurchase) { + RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous) + .fill(Color.gray400) + .frame(maxWidth: .infinity) + .frame(height: 236) + .overlay(paidPriceOverlay) + } + .buttonStyle(.plain) + } + + private var paidPriceOverlay: some View { + 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("\(post.price)") + .appFont(size: 16, weight: .medium) + .foregroundColor(.black) + } + .padding(SodaSpacing.s8) + .background(Color.white) + .clipShape(Capsule()) + } + } + + private func audioPlayButton(audioUrl: String) -> some View { + Button { + contentPlayManager.pauseAudio() + playManager.toggleContent(item: CreatorCommunityContentItem(contentId: post.postId, url: audioUrl)) + } label: { + Image(playManager.isPlaying && playManager.currentPlayingContentId == post.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) { + if !isPaidLocked { + Button { + localIsLike.toggle() + localLikeCount += localIsLike ? 1 : -1 + localLikeCount = max(0, localLikeCount) + onTapLike() + } 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(size: 16, weight: .regular) + .foregroundColor(Color.gray400) + } + } + .buttonStyle(.plain) + } + + if post.isCommentAvailable && !isPaidLocked { + Button(action: onTapComment) { + HStack(spacing: SodaSpacing.s4) { + Image("ic_feed_community_reply") + .resizable() + .frame(width: 18, height: 18) + + Text("\(post.commentCount)") + .appFont(size: 16, weight: .regular) + .foregroundColor(Color.gray400) + } + } + .buttonStyle(.plain) + } + } + .frame(height: 24) + } + + private var isPaidLocked: Bool { + post.price > 0 && post.existOrdered == false && isOwnPost == false + } + + private var shouldShowOwnerControls: Bool { + isOwnCreatorChannel && isOwnPost + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityUploadButton.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityUploadButton.swift new file mode 100644 index 00000000..740ab6a6 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityUploadButton.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct CreatorChannelCommunityUploadButton: View { + let action: () -> Void + + var body: some View { + VStack { + Button(action: action) { + HStack(spacing: SodaSpacing.s6) { + Image("ic_new_upload_community_post") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + + Text(I18n.CreatorChannelHome.uploadCommunityPost) + .appFont(size: 16, weight: .bold) + .foregroundColor(.white) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .frame(height: 52) + .padding(.horizontal, SodaSpacing.s14) + .background(Color.soda400) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + .padding(.top, SodaSpacing.s14) + .padding(.bottom, 34) + .background(Color.black) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityViewModeBar.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityViewModeBar.swift new file mode 100644 index 00000000..1a6b7bd6 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityViewModeBar.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct CreatorChannelCommunityViewModeBar: View { + let communityPostCount: Int + let viewMode: CreatorChannelCommunityViewMode + let onTapToggle: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: SodaSpacing.s8) { + HStack(spacing: SodaSpacing.s4) { + Text(I18n.CreatorChannelCommunity.totalLabel) + .appFont(size: 16, weight: .medium) + .foregroundColor(.white) + + Text("\(communityPostCount)") + .appFont(size: 16, weight: .medium) + .foregroundColor(Color.gray500) + } + + Spacer() + + Button(action: onTapToggle) { + HStack(spacing: SodaSpacing.s4) { + Text(viewModeTitle) + .appFont(size: 16, weight: .regular) + .foregroundColor(Color.gray400) + + Image(viewModeIconName) + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, SodaSpacing.s14) + .frame(height: 52) + .background(Color.black) + } + + private var viewModeTitle: String { + switch viewMode { + case .list: + return I18n.CreatorChannelCommunity.listView + case .thumbnail: + return I18n.CreatorChannelCommunity.thumbnailView + } + } + + private var viewModeIconName: String { + switch viewMode { + case .list: + return "ic_new_list" + case .thumbnail: + return "ic_new_grid" + } + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift b/SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift new file mode 100644 index 00000000..d73a5730 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift @@ -0,0 +1,128 @@ +import SwiftUI + +struct CreatorChannelCommunityTabView: View { + let creatorId: Int + let isOwnCreatorChannel: Bool + @ObservedObject var viewModel: CreatorChannelCommunityViewModel + + private let gridColumns = [ + GridItem(.flexible(), spacing: 0), + GridItem(.flexible(), spacing: 0), + GridItem(.flexible(), spacing: 0) + ] + + var body: some View { + content + .background(Color.black) + .onAppear { + if viewModel.hasLoaded == false { + viewModel.fetchFirstPage(creatorId: creatorId) + } + } + .sheet(isPresented: $viewModel.isShowCommentListView) { + NavigationStack { + CreatorCommunityCommentListView( + isPresented: $viewModel.isShowCommentListView, + creatorId: creatorId, + postId: viewModel.selectedPostId, + isShowSecret: viewModel.isShowSecret + ) + } + .toolbar(.hidden, for: .navigationBar) + } + .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1) + } + + private var content: some View { + VStack(spacing: 0) { + if isEmptyState { + CreatorChannelCommunityEmptyView() + } else { + CreatorChannelCommunityViewModeBar( + communityPostCount: viewModel.communityPostCount, + viewMode: viewModel.viewMode, + onTapToggle: viewModel.toggleViewMode + ) + + if viewModel.viewMode == .list { + listContent + } else { + gridContent + } + } + } + } + + private var listContent: some View { + LazyVStack(spacing: SodaSpacing.s8) { + ForEach(viewModel.communityPosts) { post in + let isOwnPost = post.creatorId == UserDefaults.int(forKey: .userId) + + CreatorChannelCommunityListItem( + post: post, + isOwnPost: isOwnPost, + isOwnCreatorChannel: isOwnCreatorChannel, + onTapLike: { + viewModel.communityPostLike(postId: post.postId) + }, + onTapComment: { + viewModel.openCommentList(post: post) + }, + onTapMore: { + viewModel.openReportMenu(post: post) + }, + onTapPurchase: { + viewModel.openPurchaseDialog(post: post) + } + ) + .onAppear { + viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: post) + } + } + + loadingNextPageView + } + .padding(.horizontal, SodaSpacing.s14) + .padding(.bottom, contentBottomPadding) + } + + private var gridContent: some View { + LazyVGrid(columns: gridColumns, spacing: 0) { + ForEach(viewModel.communityPosts) { post in + let isOwnPost = post.creatorId == UserDefaults.int(forKey: .userId) + + CreatorChannelCommunityGridItem( + post: post, + isOwnPost: isOwnPost, + onTapPurchase: { + viewModel.openPurchaseDialog(post: post) + } + ) + .onAppear { + viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: post) + } + } + + loadingNextPageView + } + .padding(.bottom, contentBottomPadding) + } + + @ViewBuilder + private var loadingNextPageView: some View { + if viewModel.isLoadingNextPage { + ProgressView() + .padding(.vertical, SodaSpacing.s20) + } + } + + private var isEmptyState: Bool { + guard viewModel.hasLoaded else { return false } + return viewModel.communityPostCount == 0 + } + + private var contentBottomPadding: CGFloat { + isOwnCreatorChannel ? 120 : SodaSpacing.s20 + } + +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift b/SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift new file mode 100644 index 00000000..60ddc13e --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift @@ -0,0 +1,351 @@ +import Foundation +import Combine + +import Moya + +enum CreatorChannelCommunityViewMode { + case list + case thumbnail +} + +final class CreatorChannelCommunityViewModel: ObservableObject { + private let repository = CreatorChannelCommunityRepository() + private let communityRepository = CreatorCommunityRepository() + private let reportRepository = ReportRepository() + private var subscription = Set() + private let pageSize = 20 + private var latestRequestId = 0 + + @Published var isLoading = false + @Published var isLoadingNextPage = false + @Published var response: CreatorChannelCommunityTabResponse? + @Published var communityPosts = [CreatorChannelCommunityPostItem]() + @Published var viewMode: CreatorChannelCommunityViewMode = .list + @Published var page = 0 + @Published var size = 20 + @Published var hasNext = false + @Published var hasLoaded = false + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isShowCommentListView = false { + didSet { + if !isShowCommentListView { + selectedPostId = 0 + isShowSecret = false + } + } + } + @Published var isShowReportMenu = false + @Published var isShowReportView = false { + didSet { + if !isShowReportView { + selectedPostId = 0 + } + } + } + @Published var isShowDeleteConfirm = false { + didSet { + if !isShowDeleteConfirm { + selectedPostId = 0 + } + } + } + @Published var isShowPostPurchaseView = false { + didSet { + if !isShowPostPurchaseView { + selectedPostId = 0 + selectedPostPrice = 0 + } + } + } + @Published var selectedPostId = 0 + @Published var selectedPostPrice = 0 + @Published var selectedPostIsPinned = false + @Published var isShowSecret = false + + var communityPostCount: Int { + response?.communityPostCount ?? 0 + } + + func fetchFirstPage(creatorId: Int) { + fetchCommunity(creatorId: creatorId, page: 0, isNextPage: false) + } + + func fetchNextPageIfNeeded(creatorId: Int, currentItem: CreatorChannelCommunityPostItem) { + guard communityPosts.last?.postId == currentItem.postId else { return } + guard hasNext, isLoadingNextPage == false, isLoading == false else { return } + + fetchCommunity(creatorId: creatorId, page: page + 1, isNextPage: true) + } + + func toggleViewMode() { + viewMode = viewMode == .list ? .thumbnail : .list + } + + func isPaidLocked(_ post: CreatorChannelCommunityPostItem, isOwnPost: Bool) -> Bool { + post.price > 0 && post.existOrdered == false && isOwnPost == false + } + + func openCommentList(post: CreatorChannelCommunityPostItem) { + selectedPostId = post.postId + isShowSecret = post.price > 0 && post.existOrdered && post.creatorId != UserDefaults.int(forKey: .userId) + isShowCommentListView = true + } + + func openReportMenu(post: CreatorChannelCommunityPostItem) { + selectedPostId = post.postId + selectedPostPrice = post.price + selectedPostIsPinned = post.isPinned + isShowReportMenu = true + } + + func openPurchaseDialog(post: CreatorChannelCommunityPostItem) { + selectedPostId = post.postId + selectedPostPrice = post.price + isShowPostPurchaseView = true + } + + func communityPostLike(postId: Int) { + communityRepository.communityPostLike(postId: postId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { _ in } + .store(in: &subscription) + } + + func report(reason: String) { + guard selectedPostId > 0 else { return } + + isLoading = true + let request = ReportRequest(type: .COMMUNITY_POST, reason: reason, communityPostId: selectedPostId) + reportRepository.report(request: request) + .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.applyFailureState() + } + } receiveValue: { [weak self] response in + guard let self else { return } + + self.isLoading = false + self.selectedPostId = 0 + + do { + let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } catch { + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func deleteCommunityPost(creatorId: Int) { + guard selectedPostId > 0 else { return } + + isLoading = true + let request = ModifyCommunityPostRequest(creatorCommunityId: selectedPostId, isActive: false) + var multipartData = [MultipartFormData]() + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + + guard let jsonData = try? encoder.encode(request) else { + applyFailureState() + return + } + + multipartData.append(MultipartFormData(provider: .data(jsonData), name: "request")) + communityRepository.modifyCommunityPost(parameters: multipartData) + .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.applyFailureState() + } + } receiveValue: { [weak self] response in + guard let self else { return } + + self.isLoading = false + self.selectedPostId = 0 + + do { + let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) + if decoded.success { + self.errorMessage = I18n.Explorer.deleted + self.isShowPopup = true + self.fetchFirstPage(creatorId: creatorId) + } else { + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func updateCommunityPostFixed(creatorId: Int) { + guard selectedPostId > 0 else { return } + + isLoading = true + let request = UpdateCommunityPostFixedRequest(postId: selectedPostId, isFixed: !selectedPostIsPinned) + communityRepository.updateCommunityPostFixed(request: request) + .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.applyFailureState() + } + } receiveValue: { [weak self] response in + guard let self else { return } + + self.isLoading = false + + do { + let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) + if decoded.success { + self.fetchFirstPage(creatorId: creatorId) + } else { + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func purchaseCommunityPost(creatorId: Int) { + guard selectedPostId > 0 else { return } + + isLoading = true + communityRepository.purchaseCommunityPost(postId: selectedPostId) + .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.applyFailureState() + } + } receiveValue: { [weak self] response in + guard let self else { return } + + self.isLoading = false + self.selectedPostId = 0 + + do { + let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) + if decoded.success { + self.fetchFirstPage(creatorId: creatorId) + } else { + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + self.fetchFirstPage(creatorId: creatorId) + } + } + .store(in: &subscription) + } + + private func fetchCommunity(creatorId: Int, page requestPage: Int, isNextPage: Bool) { + latestRequestId += 1 + let requestId = latestRequestId + + if isNextPage { + isLoadingNextPage = true + } else { + isLoading = true + } + + repository.getCommunity(creatorId: creatorId, page: requestPage, size: pageSize) + .sink { [weak self] result in + guard let self else { return } + guard requestId == self.latestRequestId else { return } + + switch result { + case .finished: + DEBUG_LOG("finish") + self.isLoading = false + self.isLoadingNextPage = false + case .failure(let error): + ERROR_LOG(error.localizedDescription) + self.applyFailureState() + } + } receiveValue: { [weak self] response in + guard let self else { return } + guard requestId == self.latestRequestId else { return } + + do { + let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) + if let data = decoded.data, decoded.success { + self.applySuccessState(data, isNextPage: isNextPage) + self.hasLoaded = true + } else { + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + + self.isLoading = false + self.isLoadingNextPage = false + } + .store(in: &subscription) + } + + private func applySuccessState(_ data: CreatorChannelCommunityTabResponse, isNextPage: Bool) { + response = data + page = data.page + size = data.size + hasNext = data.hasNext + + if isNextPage { + communityPosts.append(contentsOf: data.communityPosts) + } else { + communityPosts = data.communityPosts + } + } + + private func applyFailureState() { + errorMessage = I18n.Common.commonError + isShowPopup = true + hasLoaded = true + isLoading = false + isLoadingNextPage = false + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityTabResponse.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityTabResponse.swift new file mode 100644 index 00000000..b31a8312 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityTabResponse.swift @@ -0,0 +1,35 @@ +import Foundation + +struct CreatorChannelCommunityTabResponse: Decodable { + let communityPostCount: Int + let communityPosts: [CreatorChannelCommunityPostResponse] + let page: Int + let size: Int + let hasNext: Bool + + struct CreatorChannelCommunityPostResponse: Decodable, Identifiable { + let postId: Int + let creatorId: Int + let creatorNickname: String + let creatorProfileUrl: String + let createdAtUtc: String + let content: String + let imageUrl: String? + let audioUrl: String? + let price: Int + let isCommentAvailable: Bool + let likeCount: Int + let commentCount: Int + let isPinned: Bool + let existOrdered: Bool + let isLiked: Bool? + + var id: Int { postId } + + func relativeTimeText(now: Date = Date()) -> String { + DateParser.relativeTimeText(fromUTC: createdAtUtc, fallback: createdAtUtc, now: now) + } + } +} + +typealias CreatorChannelCommunityPostItem = CreatorChannelCommunityTabResponse.CreatorChannelCommunityPostResponse diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift new file mode 100644 index 00000000..8dd1c3f5 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift @@ -0,0 +1,41 @@ +import Foundation + +import Moya + +enum CreatorChannelCommunityApi { + case getCommunity(creatorId: Int, page: Int, size: Int) +} + +extension CreatorChannelCommunityApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getCommunity(let creatorId, _, _): + return "/api/v2/creator-channels/\(creatorId)/community" + } + } + + var method: Moya.Method { + switch self { + case .getCommunity: + return .get + } + } + + var task: Moya.Task { + switch self { + case .getCommunity(_, let page, let size): + return .requestParameters( + parameters: ["page": page, "size": size], + encoding: URLEncoding.queryString + ) + } + } + + var headers: [String: String]? { + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift new file mode 100644 index 00000000..64e5e9cd --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift @@ -0,0 +1,13 @@ +import Foundation +import Combine + +import CombineMoya +import Moya + +final class CreatorChannelCommunityRepository { + private let api = MoyaProvider() + + func getCommunity(creatorId: Int, page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getCommunity(creatorId: creatorId, page: page, size: size)) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift index 18c27ed4..67f48ccb 100644 --- a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift @@ -9,6 +9,7 @@ struct CreatorChannelView: View { @StateObject private var channelDonationViewModel = ChannelDonationViewModel() @StateObject private var donationViewModel = CreatorChannelDonationViewModel() @StateObject private var fanTalkViewModel = CreatorChannelFanTalkViewModel() + @StateObject private var communityViewModel = CreatorChannelCommunityViewModel() @StateObject private var liveViewModel = LiveViewModel() @StateObject private var mypageViewModel = MyPageViewModel() @AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token) @@ -142,6 +143,15 @@ struct CreatorChannelView: View { .padding(.bottom, SodaSpacing.s14) } + if isOwnCreatorChannel && viewModel.selectedTab == .community && !viewModel.isLoading { + VStack { + Spacer() + + CreatorChannelCommunityUploadButton(action: showCommunityWrite) + } + .ignoresSafeArea(.container, edges: .bottom) + } + if isShowAuthConfirmView { authConfirmDialog } @@ -168,6 +178,8 @@ struct CreatorChannelView: View { } ) } + + communityOverlay(proxy: proxy) } } .navigationBarBackButtonHidden(true) @@ -281,6 +293,12 @@ struct CreatorChannelView: View { viewModel: fanTalkViewModel, onTapWrite: showFanTalkWriteView ) + } else if viewModel.selectedTab == .community { + CreatorChannelCommunityTabView( + creatorId: creatorId, + isOwnCreatorChannel: isOwnCreatorChannel, + viewModel: communityViewModel + ) } else if viewModel.selectedTab != .home { CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title) } else if let response = viewModel.response { @@ -404,6 +422,7 @@ struct CreatorChannelView: View { AppState.shared.setAppStep( step: .creatorCommunityWrite(onSuccess: { viewModel.fetchHome(creatorId: creatorId) + communityViewModel.fetchFirstPage(creatorId: creatorId) }) ) } @@ -494,6 +513,92 @@ struct CreatorChannelView: View { isShowChannelDonationDialog = true } + @ViewBuilder + private func communityOverlay(proxy: GeometryProxy) -> some View { + if viewModel.selectedTab == .community { + if communityViewModel.isShowReportMenu { + VStack(spacing: 0) { + CreatorCommunityMenuView( + isShowing: $communityViewModel.isShowReportMenu, + isShowCreatorMenu: isOwnCreatorChannel, + isFixed: communityViewModel.selectedPostIsPinned, + fixedAction: { + communityViewModel.updateCommunityPostFixed(creatorId: creatorId) + }, + modifyAction: showCommunityModify, + deleteAction: { + if isOwnCreatorChannel { + communityViewModel.isShowDeleteConfirm = true + } + }, + reportAction: { + communityViewModel.isShowReportView = true + } + ) + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(Color(hex: "222222")) + .frame(width: proxy.size.width, height: 15.3) + } + } + .ignoresSafeArea() + } + + if communityViewModel.isShowReportView { + CreatorCommunityReportView(isShowing: $communityViewModel.isShowReportView) { reason in + communityViewModel.report(reason: reason) + } + } + + if communityViewModel.isShowDeleteConfirm { + SodaDialog( + title: I18n.Common.postDeleteTitle, + desc: I18n.Common.confirmDeleteQuestion, + confirmButtonTitle: I18n.Common.delete, + confirmButtonAction: { + communityViewModel.deleteCommunityPost(creatorId: creatorId) + communityViewModel.isShowDeleteConfirm = false + }, + cancelButtonTitle: I18n.Common.cancel, + cancelButtonAction: { + communityViewModel.isShowDeleteConfirm = false + }, + textAlignment: .center + ) + } + + if communityViewModel.isShowPostPurchaseView { + CommunityPostPurchaseDialog( + isShowing: $communityViewModel.isShowPostPurchaseView, + can: communityViewModel.selectedPostPrice, + confirmAction: { + communityViewModel.purchaseCommunityPost(creatorId: creatorId) + } + ) + } + + if communityViewModel.isLoading { + LoadingView() + } + } + } + + private func showCommunityModify() { + let postId = communityViewModel.selectedPostId + guard postId > 0 else { return } + + AppState.shared.setAppStep( + step: .creatorCommunityModify( + postId: postId, + onSuccess: { + communityViewModel.fetchFirstPage(creatorId: creatorId) + } + ) + ) + communityViewModel.selectedPostId = 0 + } + private func showFanTalkWriteView() {} } diff --git a/docs/20260705_크리에이터_채널_커뮤니티_탭/plan-task.md b/docs/20260705_크리에이터_채널_커뮤니티_탭/plan-task.md new file mode 100644 index 00000000..e2435c96 --- /dev/null +++ b/docs/20260705_크리에이터_채널_커뮤니티_탭/plan-task.md @@ -0,0 +1,221 @@ +# 크리에이터 채널 커뮤니티 탭 구현 계획 + +## 기준 문서 + +- PRD: `docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md` +- 기존 오디오 탭 PRD: `docs/20260704_크리에이터_채널_오디오_탭/prd.md` +- 기존 팬Talk 탭 PRD: `docs/20260704_크리에이터_채널_팬Talk_탭/prd.md` +- 기존 커뮤니티 그리드/리스트 전환 계획: `docs/plan-task/20260305_크리에이터커뮤니티전체보기그리드리스트전환구현.md` +- 코드 스타일: `docs/agent-guides/code-style.md` +- 빌드/검증: `docs/agent-guides/build-test-verification.md` +- Figma 전체 리스트형: `290:9061`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9061&m=dev` +- Figma 전체 썸네일형: `290:9073`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9073&m=dev` +- Figma 전체 리스트형 + 본인 채널: `665:19021`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=665-19021&m=dev` +- Figma 전체 썸네일형 + 본인 채널: Figma 설명 기준, 썸네일 UI는 `290:9073`과 동일하고 하단 고정 `커뮤니티 글 올리기` 버튼을 표시한다. +- Figma 유료 미구매 게시글: `290:9066`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9066&m=dev` +- Figma 유료 금액 우측 상단 표시: `665:19024`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=665-19024&m=dev` +- Figma Empty: `290:8994`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-8994&m=dev` + +## 구현 원칙 + +- 기존 `CreatorChannelView`의 공통 shell, header, sticky tab-bar, title bar 동작은 유지한다. +- `CreatorChannelTab.community` 콘텐츠만 신규 `CreatorChannelCommunityTabView`로 교체한다. +- 커뮤니티 탭 API는 V2 신규 endpoint를 사용한다. +- 좋아요, 댓글 sheet, 구매 dialog, 신고, 수정, 삭제, 고정, 오디오 재생은 기존 커뮤니티 구현 흐름을 가능한 한 재사용한다. +- 리스트형/썸네일형 토글은 API 재호출 없이 현재 로드된 목록만 재배치한다. +- 게시글 상세 화면은 후속 범위이므로 item tap 상세 이동은 연결하지 않는다. +- GIF는 정지 이미지로 변환하지 않고 재생 가능한 이미지 컴포넌트를 사용한다. + +## 주요 대상 파일 + +### 생성 +- `SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityTabResponse.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityViewModeBar.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityUploadButton.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityEmptyView.swift` + +### 수정 +- `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` +- `SodaLive/Sources/I18n/I18n.swift` +- 필요 시 `SodaLive.xcodeproj/project.pbxproj` + +### 확인/재사용 +- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift` +- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllItemView.swift` +- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift` +- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/Player/CreatorCommunityMediaPlayerManager.swift` +- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityRepository.swift` +- `SodaLive/Sources/Dialog/CommunityPostPurchaseDialog.swift` + +## TASK 체크리스트 + +### Phase 1: 문서와 API 모델 + +- [x] **Task 1.1: PRD와 계획 문서 생성** + - 대상 파일: + - 생성: `docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md` + - 생성: `docs/20260705_크리에이터_채널_커뮤니티_탭/plan-task.md` + - 검증 기준: + - 실행 명령: `rg "290:9061|290:9073|665:19021|290:9066|665:19024|290:8994|CreatorChannelCommunityTabResponse" docs/20260705_크리에이터_채널_커뮤니티_탭` + - 기대 결과: 구현할 UI의 Figma URL과 응답 모델 요구사항이 문서에 기록된다. + +- [x] **Task 1.2: 커뮤니티 탭 Response model 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityTabResponse.swift` + - 작업 내용: + - `CreatorChannelCommunityTabResponse`와 `CreatorChannelCommunityPostResponse`를 `Decodable`로 생성한다. + - Kotlin `Long` 필드는 Swift `Int`로 선언한다. + - `creatorProfileUrl`, `existOrdered`, `isPinned`를 포함한다. + - 검증 기준: + - 실행 명령: `rg "struct CreatorChannelCommunityTabResponse|communityPostCount|communityPosts|hasNext|struct CreatorChannelCommunityPostResponse|creatorProfileUrl|existOrdered|isPinned" SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityTabResponse.swift` + +### Phase 2: API, Repository, ViewModel + +- [x] **Task 2.1: 커뮤니티 탭 API endpoint 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift` + - 작업 내용: + - `CreatorChannelCommunityApi.getCommunity(creatorId:page:size:)`를 만든다. + - path는 `/api/v2/creator-channels/{creatorId}/community`로 구성한다. + - query는 `page`, `size`를 `URLEncoding.queryString`으로 전송한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityApi|getCommunity|/api/v2/creator-channels/|/community|page|size|URLEncoding.queryString" SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift` + +- [x] **Task 2.2: 커뮤니티 탭 Repository 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift` + - 작업 내용: + - `MoyaProvider`를 사용하는 `getCommunity(...)` publisher를 제공한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityRepository|MoyaProvider|AnyPublisher|getCommunity" SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift` + +- [x] **Task 2.3: 커뮤니티 탭 ViewModel 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift` + - 작업 내용: + - 상태는 loading, response, `communityPosts`, view mode, page, size, hasNext, hasLoaded, error toast, 선택 post id/price/action 상태를 둔다. + - 첫 페이지 조회, 다음 페이지 조회, 새로고침, view mode 토글을 제공한다. + - `communityPostCount`는 sort-bar 표시용 computed property로 제공한다. + - 유료 미구매 판정 helper를 제공한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityViewModel|fetchFirstPage|fetchNextPageIfNeeded|toggleViewMode|communityPostCount|hasNext|page|size|isPaidLocked" SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift` + +### Phase 3: 리스트형/썸네일형 UI + +- [x] **Task 3.1: 리스트형/썸네일형 토글 bar 구현** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityViewModeBar.swift` + - 수정: `SodaLive/Sources/I18n/I18n.swift` + - 작업 내용: + - 좌측에는 `전체 {communityPostCount}`를 표시한다. + - 우측 버튼은 리스트형/썸네일형 문구와 `ic_new_list` / `ic_new_grid` 아이콘을 표시한다. + - 정렬 popup이나 `ContentSort`는 사용하지 않는다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityViewModeBar|communityPostCount|ic_new_list|ic_new_grid|listView|thumbnailView" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityViewModeBar.swift SodaLive/Sources/I18n/I18n.swift` + +- [x] **Task 3.2: 리스트형 item 구현** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift` + - 작업 내용: + - Figma `290:9061`, `665:19021`, `290:9066`, `665:19024` 기준으로 작성자, 시간, 본문, 이미지/GIF, 오디오 재생 버튼, 가격, 더보기, 좋아요, 댓글 표시를 구성한다. + - `isCommentAvailable == false`이면 댓글 영역을 숨긴다. + - 유료 미구매 상태이면 회색 `RoundedRectangle`과 가격 UI를 표시하고 오디오 재생 버튼을 숨긴다. + - `imageUrl == nil`이면 유/무료 관계없이 이미지 영역을 표시하지 않는다. + - GIF 재생은 `WebImage` 등 기존 재생 가능한 이미지 컴포넌트를 우선 사용한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityListItem|creatorProfileUrl|isCommentAvailable|RoundedRectangle|audioUrl|CommunityPostPurchase|isPinned|existOrdered" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift` + +- [x] **Task 3.3: 썸네일형 grid item 구현** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift` + - 작업 내용: + - Figma `290:9073` 기준 3열 grid item을 만든다. + - 이미지/GIF가 있으면 정방형으로 표시한다. + - `imageUrl == nil`이면 유/무료 관계없이 기존 커뮤니티 전체보기 fallback처럼 글 일부를 표시한다. + - 유료 미구매 상태의 가격 UI는 중앙형으로 표시한다. + - 고정 표시는 별도로 분리한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityGridItem|aspectRatio\\(1|fallbackText|isPaidLocked|isPinned|WebImage|ic_pin" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift` + +- [x] **Task 3.4: Empty view 구현** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityEmptyView.swift` + - 수정: `SodaLive/Sources/I18n/I18n.swift` + - 작업 내용: + - `communityPostCount == 0`일 때 Figma `290:8994` 기준 empty view를 만든다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityEmptyView|communityEmpty|290:8994" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityEmptyView.swift SodaLive/Sources/I18n/I18n.swift docs/20260705_크리에이터_채널_커뮤니티_탭` + +### Phase 4: 액션 재사용과 본인 채널 CTA + +- [x] **Task 4.1: 커뮤니티 탭 화면 조립** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift` + - 작업 내용: + - 첫 진입 시 `fetchFirstPage(creatorId:)`를 호출한다. + - view mode에 따라 list 또는 grid를 표시한다. + - 마지막 item 노출 시 `fetchNextPageIfNeeded`로 append한다. + - 댓글 sheet, 구매 dialog, 신고 view, 삭제 confirm dialog, 더보기 menu 상태를 연결한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityTabView|CreatorChannelCommunityViewModeBar|CreatorChannelCommunityListItem|CreatorChannelCommunityGridItem|fetchFirstPage|fetchNextPageIfNeeded|CommunityPostPurchaseDialog|CreatorCommunityCommentListView" SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift` + +- [x] **Task 4.2: 기존 커뮤니티 mutation/action 흐름 재사용** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift` + - 확인: `SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityRepository.swift` + - 확인: `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift` + - 작업 내용: + - 좋아요, 구매, 신고, 삭제, 고정 API 호출은 기존 repository와 request 모델을 재사용한다. + - 성공 후 첫 페이지를 다시 조회한다. + - 수정은 기존 `AppState.shared.setAppStep(step: .creatorCommunityModify(...))`로 이동한다. + - 검증 기준: + - 실행 명령: `rg "communityPostLike|purchaseCommunityPost|ReportType.COMMUNITY_POST|deleteCommunityPost|updateCommunityPostFixed|creatorCommunityModify" SodaLive/Sources/V2/CreatorChannel/Community SodaLive/Sources/Explorer/Profile/CreatorCommunity` + +- [x] **Task 4.3: 본인 채널 업로드 버튼 구현** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityUploadButton.swift` + - 작업 내용: + - 본인 채널 하단 고정 `커뮤니티 글 올리기` 버튼을 만든다. + - 버튼 문구는 기존 다국어 문구를 우선 재사용한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityUploadButton|uploadCommunityPost|frame\\(maxWidth: \\.infinity|Capsule" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityUploadButton.swift` + +### Phase 5: CreatorChannelView 연결과 검증 + +- [x] **Task 5.1: 커뮤니티 탭 콘텐츠와 CTA 연결** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` + - 작업 내용: + - `CreatorChannelTab.community` 임시 화면을 `CreatorChannelCommunityTabView`로 교체한다. + - 본인 채널이고 커뮤니티 탭이면 `CreatorChannelCommunityUploadButton`을 하단 고정으로 표시한다. + - 작성 성공 후 홈과 커뮤니티 탭 첫 페이지를 갱신할 수 있도록 closure를 연결한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityTabView|CreatorChannelCommunityUploadButton|viewModel\\.selectedTab == \\.community|showCommunityWrite" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` + +- [x] **Task 5.2: 프로젝트 파일 등록과 정적 검증** + - 대상 파일: + - 확인/수정: `SodaLive.xcodeproj/project.pbxproj` + - 검증 기준: + - 실행 명령: `git diff --check -- docs/20260705_크리에이터_채널_커뮤니티_탭 SodaLive/Sources/V2/CreatorChannel SodaLive/Sources/I18n/I18n.swift SodaLive.xcodeproj/project.pbxproj` + - 기대 결과: 공백 오류 없이 성공한다. + - 실행 명령: `plutil -lint SodaLive.xcodeproj/project.pbxproj` + - 기대 결과: `OK`가 출력된다. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 기대 결과: 빌드 성공. + +## 검증 기록 + +- 2026-07-05: `docs/agent-guides/documentation-policy.md`를 확인해 신규 문서 경로를 `docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md`, `docs/20260705_크리에이터_채널_커뮤니티_탭/plan-task.md`로 결정했다. +- 2026-07-05: 기존 `docs/20260704_크리에이터_채널_오디오_탭`, `docs/20260704_크리에이터_채널_팬Talk_탭`, `docs/plan-task/20260305_크리에이터커뮤니티전체보기그리드리스트전환구현.md`를 확인해 문서 형식과 구현 계획 단위를 맞췄다. +- 2026-07-05: `CreatorChannelView`, 기존 `CreatorCommunityAllView`, `CreatorCommunityAllItemView`, `CreatorCommunityAllViewModel`, `GetCommunityPostListResponse`를 확인해 V2 커뮤니티 탭 전용 신규 파일과 기존 커뮤니티 액션 재사용 범위를 정했다. +- 2026-07-05: 사용자 추가 확인으로 Empty Figma `290:8994`, 썸네일형 유료 미구매 가격 UI 중앙형, `imageUrl == nil`일 때 유/무료 관계없이 이미지 미표시 규칙을 반영했다. +- 2026-07-05: `CreatorChannelCommunityTabResponse`, `CreatorChannelCommunityApi`, `CreatorChannelCommunityRepository`, `CreatorChannelCommunityViewModel`을 생성하고 V2 커뮤니티 탭 목록 조회, 페이지네이션, 리스트/썸네일 전환, 기존 커뮤니티 mutation/action 재사용 흐름을 연결했다. +- 2026-07-05: `CreatorChannelCommunityTabView`와 하위 컴포넌트 5개를 생성해 리스트형/썸네일형/Empty/본인 채널 업로드 CTA를 연결하고, `CreatorChannelView`의 `.community` 탭 임시 화면을 신규 탭으로 교체했다. +- 2026-07-05: `I18n.CreatorChannelCommunity` 문구를 추가하고, `ic_new_grid`, `ic_new_list` 신규 asset을 사용하는 view mode toggle을 구현했다. +- 2026-07-05: `git diff --check`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`로 정적 검증과 Debug 빌드를 확인했다. diff --git a/docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md b/docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md new file mode 100644 index 00000000..32c377ef --- /dev/null +++ b/docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md @@ -0,0 +1,192 @@ +# PRD: 크리에이터 채널 커뮤니티 탭 + +## 1. Overview +크리에이터 채널 공통 shell의 `커뮤니티` 탭에서 크리에이터의 커뮤니티 게시글을 리스트형 또는 썸네일형으로 제공한다. 상단 title bar, header, sticky tab-bar는 기존 크리에이터 채널 홈/라이브/오디오/시리즈/팬Talk/후원 구현을 재사용하고, tab-bar 아래 콘텐츠만 커뮤니티 탭 전용 API 응답으로 구성한다. + +API는 `GET /api/v2/creator-channels/{creatorId}/community`를 사용한다. `creatorId`는 path variable이며, query parameter는 `page`, `size`를 사용한다. 기본값은 `page=0`, `size=20`이다. + +Figma 참조: +- 전체 리스트형: `290:9061`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9061&m=dev` +- 전체 썸네일형: `290:9073`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9073&m=dev` +- 전체 리스트형 + 본인 채널: `665:19021`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=665-19021&m=dev` +- 전체 썸네일형 + 본인 채널: Figma 설명 기준, 썸네일 UI는 `290:9073`과 동일하고 하단 고정 `커뮤니티 글 올리기` 버튼을 표시한다. +- 유료이고 구매하지 않은 게시글: `290:9066`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9066&m=dev` +- 유료 금액 우측 상단 표시: `665:19024`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=665-19024&m=dev` +- Empty: `290:8994`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-8994&m=dev` + +## 2. Problem +- 현재 `CreatorChannelTab.community`는 임시 화면으로 표시되어 V2 크리에이터 채널 안에서 커뮤니티 게시글 목록을 확인할 수 없다. +- 기존 `CreatorCommunityAllView`는 구 프로필 커뮤니티 전체보기 화면이고, V2 채널 탭의 sticky header, tab-bar, sort-bar 구조와 직접 맞지 않는다. +- 커뮤니티 탭은 오디오 탭의 정렬 선택과 달리 리스트형/썸네일형 보기 전환 버튼이 필요하다. +- 유료 미구매 게시글, 댓글 비활성 게시글, 본인 채널 게시글의 표시 조건이 기존 전체보기와 일부 달라 V2 탭 기준으로 명확히 분리해야 한다. + +## 3. Goals +- `CreatorChannelTab.community` 선택 시 커뮤니티 탭 API를 호출하고 응답 데이터로 화면을 구성한다. +- 기본 query는 `page=0`, `size=20`이다. +- sort-bar 좌측에는 `전체 {communityPostCount}`를 표시한다. +- sort-bar 우측은 정렬이 아니라 리스트형/썸네일형 토글 버튼으로 제공한다. +- 기본 보기 방식은 리스트형이고, 기본 아이콘은 `ic_new_list`를 사용한다. +- 토글 후 썸네일형에서는 `ic_new_grid`를 사용한다. +- 리스트형/썸네일형 표시 문구는 `I18n`에 ko/en/ja 다국어로 추가한다. +- 리스트형은 기존 커뮤니티 리스트 카드의 정보 구조를 따르되 V2 채널 탭 디자인에 맞춰 작성한다. +- 썸네일형은 3열 grid로 표시하고, 이미지가 있으면 이미지 또는 GIF를 재생 가능하게 표시한다. +- 썸네일형에서 `imageUrl == nil`이면 유/무료 관계없이 이미지를 표시하지 않고 기존 크리에이터 커뮤니티 페이지의 fallback 규칙을 참고해 글 일부를 표시한다. +- 유료 미구매 게시글은 이미지 영역에 회색 `RoundedRectangle`을 표시하고, 오디오 재생 버튼은 표시하지 않는다. +- 본인 또는 구매한 유저는 이미지와 중앙 오디오 재생 버튼을 기존 커뮤니티 페이지와 동일한 방식으로 표시한다. +- 댓글을 사용할 수 없는 게시글은 댓글 아이콘과 댓글 개수를 숨긴다. +- 본인 채널에 본인이 쓴 커뮤니티 게시글에서만 상단 더보기와 가격 표시를 표시한다. +- 본인 채널이면 하단 고정 `커뮤니티 글 올리기` 버튼을 표시한다. +- 게시글 우측 더보기 액션은 기존 커뮤니티 전체보기의 수정/삭제/신고/고정 액션 흐름을 재사용한다. + +## 4. Non-Goals +- 크리에이터 채널 공통 shell, header, sticky tab-bar 동작을 다시 설계하지 않는다. +- 커뮤니티 게시글 상세 화면은 이번 범위에서 구현하지 않는다. +- 커뮤니티 글쓰기/수정 화면 자체를 새로 구현하지 않고 기존 `creatorCommunityWrite` / `creatorCommunityModify` navigation step을 재사용한다. +- 커뮤니티 댓글 리스트, 댓글 작성, 신고, 삭제, 구매 mutation API를 새로 만들지 않고 기존 커뮤니티 구현의 흐름을 재사용한다. +- 오디오 탭의 정렬 popup을 커뮤니티 탭에 표시하지 않는다. +- Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다. +- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다. + +## 5. Core Requirements + +### 5.1 API +- Method: `GET` +- Path: `/api/v2/creator-channels/{creatorId}/community` +- Path parameter: + - `creatorId` +- Query parameters: + - `page`: 기본값 `0` + - `size`: 기본값 `20` +- 응답 래퍼는 기존 패턴대로 `ApiResponse`로 디코딩한다. + +```kotlin +data class CreatorChannelCommunityTabResponse( + val communityPostCount: Int, + val communityPosts: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) + +data class CreatorChannelCommunityPostResponse( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileUrl: String, + val createdAtUtc: String, + val content: String, + val imageUrl: String?, + val audioUrl: String?, + val price: Int, + @JsonProperty("isCommentAvailable") + val isCommentAvailable: Boolean, + val likeCount: Int, + val commentCount: Int, + @JsonProperty("isPinned") + val isPinned: Boolean, + val existOrdered: Boolean +) +``` + +- Kotlin `Long`은 기존 V2 모델 관례대로 Swift `Int`로 선언한다. +- `creatorProfileUrl`, `existOrdered`는 서버 응답에 추가되는 필드로 포함한다. +- `createdAtUtc`는 기존 `DateParser.relativeTimeText(fromUTC:fallback:now:)` 계열 helper를 사용해 상대 시간으로 표시한다. + +### 5.2 View mode toggle +- 기본 보기 방식은 리스트형이다. +- sort-bar 우측 버튼을 터치할 때마다 리스트형과 썸네일형을 토글한다. +- 리스트형 상태에서는 버튼에 `ic_new_list`와 리스트형 문구를 표시한다. +- 썸네일형 상태에서는 버튼에 `ic_new_grid`와 썸네일형 문구를 표시한다. +- 리스트형/썸네일형 문구는 `I18n.CreatorChannelCommunity` 또는 같은 범위의 신규 enum에 ko/en/ja로 추가한다. +- 보기 방식 전환은 현재까지 로드된 `communityPosts`를 그대로 사용하고 API를 다시 호출하지 않는다. + +### 5.3 List layout +- 리스트형은 Figma `290:9061`과 `665:19021`을 기준으로 표시한다. +- 작성자 프로필 이미지, 닉네임, 상대 시간, 본문, 이미지/오디오 영역, 좋아요 수, 댓글 수를 표시한다. +- `isPinned == true`이면 게시글에 고정 표시를 제공한다. +- `price > 0 && existOrdered == false && isOwnPost == false`이면 유료 미구매 상태로 표시한다. +- 유료 미구매 상태에서는 이미지 URL도 `nil`로 내려올 수 있으므로 이미지 영역에 회색 `RoundedRectangle`을 표시한다. +- 유료 미구매 상태에서는 오디오 재생 버튼을 표시하지 않는다. +- 본인 또는 구매한 유저는 이미지가 있으면 이미지 또는 GIF를 표시하고, `audioUrl != nil`이면 이미지 중앙에 재생 버튼을 표시한다. +- GIF URL은 정지 이미지로 변환하지 않고 재생 가능한 이미지 컴포넌트를 사용한다. +- `imageUrl == nil`이면 유/무료 관계없이 이미지 영역을 표시하지 않는다. +- `isCommentAvailable == false`이면 댓글 아이콘과 댓글 개수를 숨긴다. + +### 5.4 Thumbnail layout +- 썸네일형은 Figma `290:9073` 기준 3열 grid로 표시한다. +- 이미지 또는 GIF가 있으면 정방형 썸네일에 표시한다. +- 이미지가 없으면 유/무료 관계없이 기존 `CreatorCommunityAllGridItemView`의 fallback 규칙을 참고해 글 일부를 표시한다. +- 유료 미구매 게시글은 회색 배경과 중앙형 가격 UI를 표시한다. +- `isPinned == true`이면 썸네일 우측 상단에 고정 표시를 제공한다. +- grid item tap은 후속 상세 화면 구현 전까지 상세 이동을 연결하지 않는다. + +### 5.5 Paid post display +- 유료 미구매 판정은 `price > 0 && existOrdered == false && isOwnPost == false`이다. +- 리스트형 중앙 가격 표시는 Figma `290:9066`을 따른다. +- 우측 상단 가격 표시는 Figma `665:19024`를 따른다. +- 썸네일형 유료 미구매 가격 UI는 중앙형을 사용한다. +- 상단 더보기와 가격 표시는 본인 채널에 본인이 쓴 커뮤니티 게시글에서만 표시한다. +- 유료 미구매 게시글의 구매 dialog는 기존 `CommunityPostPurchaseDialog`를 재사용한다. +- 구매 성공 후 현재 페이지 범위를 첫 페이지부터 다시 조회하거나 해당 item을 갱신한다. + +### 5.6 Actions +- 좋아요는 기존 `CreatorCommunityRepository.communityPostLike(postId:)` 흐름을 재사용한다. +- 댓글 목록은 기존 `CreatorCommunityCommentListView`를 재사용하되, `isCommentAvailable == false`이면 진입 UI를 표시하지 않는다. +- 더보기 메뉴는 기존 `CreatorCommunityMenuView` 또는 V2 anchored popup 패턴 중 구현 시점에 더 작은 변경으로 맞는 방식을 사용한다. +- 본인 채널의 내 게시글에서 더보기 메뉴는 기존과 동일하게 고정/수정/삭제 액션을 제공한다. +- 내 게시글이 아니면 신고 액션을 기존 `CreatorCommunityReportView`와 `ReportType.COMMUNITY_POST` 흐름으로 처리한다. +- 수정 성공, 삭제 성공, 고정 상태 변경 성공 후 커뮤니티 탭 첫 페이지를 다시 조회한다. + +### 5.7 Own channel CTA +- 본인 채널이고 `selectedTab == .community`이면 하단 고정 `커뮤니티 글 올리기` 버튼을 표시한다. +- 버튼 문구는 기존 `I18n.CreatorChannelHome.uploadCommunityPost` 또는 동일 의미의 기존 다국어 문구를 우선 재사용한다. +- 버튼 tap은 기존 `showCommunityWrite()` 흐름을 재사용한다. +- 글 작성 성공 후 홈 정보와 커뮤니티 탭 목록을 갱신한다. + +### 5.8 Pagination and state +- 첫 진입 시 `page=0`, `size=20`으로 조회한다. +- 다음 페이지 로딩 중 중복 호출을 막는다. +- 마지막 item이 화면에 나타났고 `hasNext == true`이면 `page + 1`을 조회한다. +- 다음 페이지 성공 시 기존 `communityPosts` 뒤에 append한다. +- 첫 페이지 재조회 시 기존 목록을 교체한다. +- Empty 상태는 `communityPostCount == 0` 기준으로 판정하고 Figma `290:8994`를 따른다. + +## 6. Success Criteria +- 커뮤니티 탭 진입 시 `GET /api/v2/creator-channels/{creatorId}/community?page=0&size=20`가 호출된다. +- count bar에는 `전체 {communityPostCount}`가 표시된다. +- 우측 버튼은 정렬이 아니라 리스트형/썸네일형 토글로 동작한다. +- 기본값은 리스트형이며 `ic_new_list` 상태로 표시된다. +- 토글 시 썸네일형으로 바뀌고 `ic_new_grid` 상태로 표시된다. +- 리스트형/썸네일형 문구는 ko/en/ja 다국어로 제공된다. +- 유료 미구매 게시글은 회색 `RoundedRectangle` 이미지 영역과 가격 UI를 표시하고 오디오 재생 버튼은 표시하지 않는다. +- 본인 또는 구매한 유저의 게시글은 이미지/GIF와 중앙 오디오 재생 버튼을 표시한다. +- `isCommentAvailable == false`이면 댓글 아이콘과 댓글 개수가 표시되지 않는다. +- 본인 채널의 내 게시글에서만 상단 더보기와 가격 표시가 노출된다. +- 본인 채널이면 리스트형/썸네일형 모두에서 하단 고정 `커뮤니티 글 올리기` 버튼이 표시된다. +- 게시글 더보기 액션은 기존 커뮤니티 페이지와 동일한 수정/삭제/신고/고정 흐름을 사용한다. +- `communityPostCount == 0`이면 커뮤니티 empty 상태가 표시된다. +- 목록 하단 도달 시 `hasNext == true`이면 다음 페이지가 append된다. + +## 7. Technical Constraints +- 기능 변경은 `SodaLive/Sources/V2/CreatorChannel/**` 하위에서 해결한다. +- 커뮤니티 탭 전용 View, ViewModel, Repository, API, 모델은 `SodaLive/Sources/V2/CreatorChannel/Community/**` 아래에 둔다. +- 기존 구 커뮤니티 전체보기 파일은 필요한 재사용 지점만 참고하고, V2 탭 구현을 위해 직접 대규모 수정하지 않는다. +- 기존 커뮤니티 mutation, 댓글, 구매, 신고, 오디오 플레이어 흐름은 가능한 한 재사용한다. +- 신규 문구는 `SodaLive/Sources/I18n/I18n.swift`에 ko/en/ja를 추가한다. +- 프로젝트 설정 변경은 신규 Swift 파일 target 등록이 필요한 경우에만 수행한다. + +## 8. Assumptions +- `creatorProfileUrl`과 `existOrdered`는 커뮤니티 탭 API 응답에 포함된다. +- 현재 로그인 사용자의 id는 기존 관례대로 `UserDefaults.int(forKey: .userId)`로 확인한다. +- `isOwnPost`는 `post.creatorId == UserDefaults.int(forKey: .userId)`로 판정한다. +- 커뮤니티 게시글 상세 화면은 후속 범위이므로 grid/list item tap 상세 이동은 이번 작업에서 연결하지 않는다. +- 본인 채널 여부는 `CreatorChannelView`의 `isOwnCreatorChannel` 값을 커뮤니티 탭에 주입한다. + +## 9. Open Questions +- 해당 없음 + +## 10. Verification Notes +- 2026-07-05: 사용자 제공 API, 응답 모델, Figma URL, 본인 채널/유료/댓글/토글 요구사항을 기준으로 PRD를 작성했다. +- 2026-07-05: `docs/agent-guides/documentation-policy.md`, 기존 크리에이터 채널 오디오/팬Talk 문서, `CreatorChannelView`, 기존 `CreatorCommunityAllView`, `CreatorCommunityAllItemView`, `CreatorCommunityAllViewModel`을 확인해 문서 범위를 정했다. +- 2026-07-05: 사용자 추가 확인으로 Empty Figma `290:8994`, 썸네일형 유료 미구매 가격 UI 중앙형, `imageUrl == nil`일 때 유/무료 관계없이 이미지 미표시 규칙을 반영했다.