feat(creator): 커뮤니티 탭을 추가한다
This commit is contained in:
@@ -1875,6 +1875,36 @@
|
|||||||
CC0530082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0500082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift */; };
|
CC0530082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0500082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift */; };
|
||||||
CC0530092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0500092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift */; };
|
CC0530092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0500092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift */; };
|
||||||
CC05300A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC05000A2FF0500000A5BBA3 /* CreatorChannelDonationCard.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 */; };
|
CD256952CC366FFB67557807 /* CreatorChannelFanTalkApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 002435A7145D59CCCA8F76D1 /* CreatorChannelFanTalkApi.swift */; };
|
||||||
CE935959F7A2BF7301F517DB /* CreatorChannelFanTalkTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1C1B0C73E8D92112E47610 /* CreatorChannelFanTalkTabResponse.swift */; };
|
CE935959F7A2BF7301F517DB /* CreatorChannelFanTalkTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1C1B0C73E8D92112E47610 /* CreatorChannelFanTalkTabResponse.swift */; };
|
||||||
CFC28133D4D60FD1E436D97C /* CreatorChannelFanTalkTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7923ABAD25E9CC4840EC94 /* CreatorChannelFanTalkTabView.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 = "<group>"; };
|
CC0500082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationEmptyView.swift; sourceTree = "<group>"; };
|
||||||
CC0500092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationFloatingButton.swift; sourceTree = "<group>"; };
|
CC0500092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationFloatingButton.swift; sourceTree = "<group>"; };
|
||||||
CC05000A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationCard.swift; sourceTree = "<group>"; };
|
CC05000A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationCard.swift; sourceTree = "<group>"; };
|
||||||
|
CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityTabResponse.swift; sourceTree = "<group>"; };
|
||||||
|
CC0600022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityApi.swift; sourceTree = "<group>"; };
|
||||||
|
CC0600032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityRepository.swift; sourceTree = "<group>"; };
|
||||||
|
CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityTabView.swift; sourceTree = "<group>"; };
|
||||||
|
CC0600062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityViewModeBar.swift; sourceTree = "<group>"; };
|
||||||
|
CC0600072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityListItem.swift; sourceTree = "<group>"; };
|
||||||
|
CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityGridItem.swift; sourceTree = "<group>"; };
|
||||||
|
CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityUploadButton.swift; sourceTree = "<group>"; };
|
||||||
|
CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityEmptyView.swift; sourceTree = "<group>"; };
|
||||||
CC73FFC54CB1D988053059D1 /* LanguageEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageEnvironment.swift; sourceTree = "<group>"; };
|
CC73FFC54CB1D988053059D1 /* LanguageEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageEnvironment.swift; sourceTree = "<group>"; };
|
||||||
CE82912F2F111593BE42E33C /* CommunityPostCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CommunityPostCard.swift; sourceTree = "<group>"; };
|
CE82912F2F111593BE42E33C /* CommunityPostCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CommunityPostCard.swift; sourceTree = "<group>"; };
|
||||||
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 = "<group>"; };
|
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 = "<group>"; };
|
||||||
@@ -5430,6 +5470,7 @@
|
|||||||
CC0300112FF0300000A5BBA3 /* Audio */,
|
CC0300112FF0300000A5BBA3 /* Audio */,
|
||||||
CC0400112FF0400000A5BBA3 /* Series */,
|
CC0400112FF0400000A5BBA3 /* Series */,
|
||||||
CC05001B2FF0500000A5BBA3 /* Donation */,
|
CC05001B2FF0500000A5BBA3 /* Donation */,
|
||||||
|
CC06000E2FF0600000A5BBA3 /* Community */,
|
||||||
CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */,
|
CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */,
|
||||||
CC0100092FF0100000A5BBA3 /* CreatorChannelViewModel.swift */,
|
CC0100092FF0100000A5BBA3 /* CreatorChannelViewModel.swift */,
|
||||||
AA4588B91C0F73344C3DC4A4 /* FanTalk */,
|
AA4588B91C0F73344C3DC4A4 /* FanTalk */,
|
||||||
@@ -5615,6 +5656,47 @@
|
|||||||
path = Donation;
|
path = Donation;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
CC06000B2FF0600000A5BBA3 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CC06000C2FF0600000A5BBA3 /* Repository */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CC0600022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift */,
|
||||||
|
CC0600032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift */,
|
||||||
|
);
|
||||||
|
path = Repository;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CC06000D2FF0600000A5BBA3 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CC0600062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift */,
|
||||||
|
CC0600072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift */,
|
||||||
|
CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */,
|
||||||
|
CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */,
|
||||||
|
CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CC06000E2FF0600000A5BBA3 /* Community */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CC06000B2FF0600000A5BBA3 /* Models */,
|
||||||
|
CC06000C2FF0600000A5BBA3 /* Repository */,
|
||||||
|
CC06000D2FF0600000A5BBA3 /* Components */,
|
||||||
|
CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */,
|
||||||
|
CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */,
|
||||||
|
);
|
||||||
|
path = Community;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D37878F5929361B43B1E3C5C /* Card */ = {
|
D37878F5929361B43B1E3C5C /* Card */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -6099,6 +6181,16 @@
|
|||||||
CC0510082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */,
|
CC0510082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */,
|
||||||
CC0510092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */,
|
CC0510092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */,
|
||||||
CC05100A2FF0500000A5BBA3 /* CreatorChannelDonationCard.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 */,
|
409E35082B0759DE00CCB985 /* MoyaProviderExtension.swift in Sources */,
|
||||||
40DDBD772B2B4FD700A2E8DA /* CreatorCommunityMoreItemView.swift in Sources */,
|
40DDBD772B2B4FD700A2E8DA /* CreatorCommunityMoreItemView.swift in Sources */,
|
||||||
400EA7742A89C42C004992CF /* StartLiveRequest.swift in Sources */,
|
400EA7742A89C42C004992CF /* StartLiveRequest.swift in Sources */,
|
||||||
@@ -6945,6 +7037,16 @@
|
|||||||
CC0520082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */,
|
CC0520082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */,
|
||||||
CC0520092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */,
|
CC0520092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */,
|
||||||
CC05200A2FF0500000A5BBA3 /* CreatorChannelDonationCard.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 */,
|
401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */,
|
||||||
401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
|
401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
|
||||||
401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
|
401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
|
||||||
@@ -7817,6 +7919,16 @@
|
|||||||
CC0530082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */,
|
CC0530082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */,
|
||||||
CC0530092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */,
|
CC0530092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */,
|
||||||
CC05300A2FF0500000A5BBA3 /* CreatorChannelDonationCard.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 */,
|
401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */,
|
||||||
401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
|
401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
|
||||||
401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
|
401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
|
||||||
|
|||||||
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_grid.imageset/Contents.json
vendored
Normal file
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_grid.imageset/Contents.json
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_grid.imageset/ic_new_grid.png
vendored
Normal file
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_grid.imageset/ic_new_grid.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 529 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_list.imageset/Contents.json
vendored
Normal file
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_list.imageset/Contents.json
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_list.imageset/ic_new_list.png
vendored
Normal file
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_list.imageset/ic_new_list.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 304 B |
@@ -52,14 +52,14 @@ struct CreatorCommunityReportView: View {
|
|||||||
|
|
||||||
Text(I18n.Common.cancel)
|
Text(I18n.Common.cancel)
|
||||||
.appFont(size: 13.3, weight: .medium)
|
.appFont(size: 13.3, weight: .medium)
|
||||||
.foregroundColor(Color(hex: "9970ff"))
|
.foregroundColor(Color.soda400)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
isShowing = false
|
isShowing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(I18n.Report.reportAction)
|
Text(I18n.Report.reportAction)
|
||||||
.appFont(size: 13.3, weight: .medium)
|
.appFont(size: 13.3, weight: .medium)
|
||||||
.foregroundColor(Color(hex: "9970ff"))
|
.foregroundColor(Color.soda400)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let selectedIndex = selectedIndex {
|
if let selectedIndex = selectedIndex {
|
||||||
confirmAction(reasons[selectedIndex])
|
confirmAction(reasons[selectedIndex])
|
||||||
|
|||||||
@@ -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 {
|
enum HomeRecommendation {
|
||||||
static var activityLive: String {
|
static var activityLive: String {
|
||||||
pick(ko: "라이브", en: "Live", ja: "ライブ")
|
pick(ko: "라이브", en: "Live", ja: "ライブ")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<AnyCancellable>()
|
||||||
|
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<CreatorChannelCommunityTabResponse>.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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))"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
import CombineMoya
|
||||||
|
import Moya
|
||||||
|
|
||||||
|
final class CreatorChannelCommunityRepository {
|
||||||
|
private let api = MoyaProvider<CreatorChannelCommunityApi>()
|
||||||
|
|
||||||
|
func getCommunity(creatorId: Int, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
return api.requestPublisher(.getCommunity(creatorId: creatorId, page: page, size: size))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ struct CreatorChannelView: View {
|
|||||||
@StateObject private var channelDonationViewModel = ChannelDonationViewModel()
|
@StateObject private var channelDonationViewModel = ChannelDonationViewModel()
|
||||||
@StateObject private var donationViewModel = CreatorChannelDonationViewModel()
|
@StateObject private var donationViewModel = CreatorChannelDonationViewModel()
|
||||||
@StateObject private var fanTalkViewModel = CreatorChannelFanTalkViewModel()
|
@StateObject private var fanTalkViewModel = CreatorChannelFanTalkViewModel()
|
||||||
|
@StateObject private var communityViewModel = CreatorChannelCommunityViewModel()
|
||||||
@StateObject private var liveViewModel = LiveViewModel()
|
@StateObject private var liveViewModel = LiveViewModel()
|
||||||
@StateObject private var mypageViewModel = MyPageViewModel()
|
@StateObject private var mypageViewModel = MyPageViewModel()
|
||||||
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
|
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
|
||||||
@@ -142,6 +143,15 @@ struct CreatorChannelView: View {
|
|||||||
.padding(.bottom, SodaSpacing.s14)
|
.padding(.bottom, SodaSpacing.s14)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isOwnCreatorChannel && viewModel.selectedTab == .community && !viewModel.isLoading {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
CreatorChannelCommunityUploadButton(action: showCommunityWrite)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(.container, edges: .bottom)
|
||||||
|
}
|
||||||
|
|
||||||
if isShowAuthConfirmView {
|
if isShowAuthConfirmView {
|
||||||
authConfirmDialog
|
authConfirmDialog
|
||||||
}
|
}
|
||||||
@@ -168,6 +178,8 @@ struct CreatorChannelView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
communityOverlay(proxy: proxy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
@@ -281,6 +293,12 @@ struct CreatorChannelView: View {
|
|||||||
viewModel: fanTalkViewModel,
|
viewModel: fanTalkViewModel,
|
||||||
onTapWrite: showFanTalkWriteView
|
onTapWrite: showFanTalkWriteView
|
||||||
)
|
)
|
||||||
|
} else if viewModel.selectedTab == .community {
|
||||||
|
CreatorChannelCommunityTabView(
|
||||||
|
creatorId: creatorId,
|
||||||
|
isOwnCreatorChannel: isOwnCreatorChannel,
|
||||||
|
viewModel: communityViewModel
|
||||||
|
)
|
||||||
} else if viewModel.selectedTab != .home {
|
} else if viewModel.selectedTab != .home {
|
||||||
CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title)
|
CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title)
|
||||||
} else if let response = viewModel.response {
|
} else if let response = viewModel.response {
|
||||||
@@ -404,6 +422,7 @@ struct CreatorChannelView: View {
|
|||||||
AppState.shared.setAppStep(
|
AppState.shared.setAppStep(
|
||||||
step: .creatorCommunityWrite(onSuccess: {
|
step: .creatorCommunityWrite(onSuccess: {
|
||||||
viewModel.fetchHome(creatorId: creatorId)
|
viewModel.fetchHome(creatorId: creatorId)
|
||||||
|
communityViewModel.fetchFirstPage(creatorId: creatorId)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -494,6 +513,92 @@ struct CreatorChannelView: View {
|
|||||||
isShowChannelDonationDialog = true
|
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() {}
|
private func showFanTalkWriteView() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
221
docs/20260705_크리에이터_채널_커뮤니티_탭/plan-task.md
Normal file
221
docs/20260705_크리에이터_채널_커뮤니티_탭/plan-task.md
Normal file
@@ -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<CreatorChannelCommunityApi>`를 사용하는 `getCommunity(...)` publisher를 제공한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "CreatorChannelCommunityRepository|MoyaProvider<CreatorChannelCommunityApi>|AnyPublisher<Response, MoyaError>|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 빌드를 확인했다.
|
||||||
192
docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md
Normal file
192
docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md
Normal file
@@ -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<CreatorChannelCommunityTabResponse>`로 디코딩한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class CreatorChannelCommunityTabResponse(
|
||||||
|
val communityPostCount: Int,
|
||||||
|
val communityPosts: List<CreatorChannelCommunityPostResponse>,
|
||||||
|
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`일 때 유/무료 관계없이 이미지 미표시 규칙을 반영했다.
|
||||||
Reference in New Issue
Block a user