From ce923ab2d79377d9a5abb5e672ed8a21e274f862 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Fri, 3 Jul 2026 23:53:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=ED=83=AD=20=EA=B8=B0=EB=B0=98=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive.xcodeproj/project.pbxproj | 86 ++++- SodaLive/Sources/I18n/I18n.swift | 11 + .../Components/CreatorChannelSortBar.swift | 47 +++ .../CreatorChannelSortBottomSheet.swift | 45 +++ .../Live/CreatorChannelLiveViewModel.swift | 103 ++++++ .../CreatorChannelLiveTabResponse.swift | 11 + .../Repository/CreatorChannelLiveApi.swift | 41 +++ .../CreatorChannelLiveRepository.swift | 13 + .../CreatorChannel/Models/ContentSort.swift | 24 ++ .../plan-task.md | 308 ++++++++++++++++++ .../20260703_크리에이터_채널_라이브_탭/prd.md | 258 +++++++++++++++ 11 files changed, 944 insertions(+), 3 deletions(-) create mode 100644 SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBottomSheet.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveViewModel.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Live/Models/CreatorChannelLiveTabResponse.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveApi.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveRepository.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Models/ContentSort.swift create mode 100644 docs/20260703_크리에이터_채널_라이브_탭/plan-task.md create mode 100644 docs/20260703_크리에이터_채널_라이브_탭/prd.md diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 0b6d5524..4239c65d 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -7,6 +7,27 @@ objects = { /* Begin PBXBuildFile section */ + CC01012A2FF0100000A5BBA3 /* ContentSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002A2FF0100000A5BBA3 /* ContentSort.swift */; }; + CC01022A2FF0100000A5BBA3 /* ContentSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002A2FF0100000A5BBA3 /* ContentSort.swift */; }; + CC01032A2FF0100000A5BBA3 /* ContentSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002A2FF0100000A5BBA3 /* ContentSort.swift */; }; + CC01012B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */; }; + CC01022B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */; }; + CC01032B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */; }; + CC01012C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift */; }; + CC01022C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift */; }; + CC01032C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift */; }; + CC01012D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift */; }; + CC01022D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift */; }; + CC01032D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift */; }; + CC01012E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift */; }; + CC01022E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift */; }; + CC01032E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift */; }; + CC01012F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift */; }; + CC01022F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift */; }; + CC01032F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift */; }; + CC0101302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift */; }; + CC0102302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift */; }; + CC0103302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift */; }; 00C796627A6758D271215EC5 /* CreatorProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */; }; 042BEE0C973700FB5F4540CD /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; }; 0470E68C6F302A131264E3AF /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; }; @@ -1691,7 +1712,6 @@ CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift */; }; CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift */; }; CC0101132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */; }; - CC0101292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */; }; CC0101142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift */; }; CC0101152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; }; CC0101162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; }; @@ -1706,6 +1726,7 @@ CC01011F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift */; }; CC0101272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift */; }; CC0101282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift */; }; + CC0101292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */; }; CC0102012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift */; }; CC0102032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift */; }; CC0102042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift */; }; @@ -1717,7 +1738,6 @@ CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift */; }; CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift */; }; CC0102132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */; }; - CC0102292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */; }; CC0102142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift */; }; CC0102152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; }; CC0102162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; }; @@ -1732,6 +1752,7 @@ CC01021F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift */; }; CC0102272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift */; }; CC0102282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift */; }; + CC0102292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */; }; CC0103012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift */; }; CC0103032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift */; }; CC0103042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift */; }; @@ -1743,7 +1764,6 @@ CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift */; }; CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift */; }; CC0103132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */; }; - CC0103292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */; }; CC0103142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift */; }; CC0103152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; }; CC0103162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; }; @@ -1758,6 +1778,7 @@ CC01031F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift */; }; CC0103272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift */; }; CC0103282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift */; }; + CC0103292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */; }; D1C65856889B16DFDB69A004 /* PushNotificationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDDF6BBD0192819B1F5B3FE /* PushNotificationRepository.swift */; }; D2A268C7346E84E6B455E25A /* Pods_SodaLive_dev.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F4E5BB7961B7E6BB68C3379 /* Pods_SodaLive_dev.framework */; }; DD202E48D61F1CE8108AD164 /* LineSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 6DBA0C325ACEC3FCDB22490D /* LineSDK */; }; @@ -1780,6 +1801,13 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + CC01002A2FF0100000A5BBA3 /* ContentSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSort.swift; sourceTree = ""; }; + CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSortBar.swift; sourceTree = ""; }; + CC01002C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSortBottomSheet.swift; sourceTree = ""; }; + CC01002D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLiveTabResponse.swift; sourceTree = ""; }; + CC01002E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLiveApi.swift; sourceTree = ""; }; + CC01002F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLiveRepository.swift; sourceTree = ""; }; + CC0100302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLiveViewModel.swift; sourceTree = ""; }; 0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = ""; }; 0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = ""; }; 189350EA101CF12EEBA6EC1D /* MainTabBarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTabBarView.swift; sourceTree = ""; }; @@ -2704,6 +2732,33 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + CC0100312FF0100000A5BBA3 /* Models */ = { + isa = PBXGroup; + children = ( + CC01002D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; + CC0100322FF0100000A5BBA3 /* Repository */ = { + isa = PBXGroup; + children = ( + CC01002E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift */, + CC01002F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift */, + ); + path = Repository; + sourceTree = ""; + }; + CC0100332FF0100000A5BBA3 /* Live */ = { + isa = PBXGroup; + children = ( + CC0100312FF0100000A5BBA3 /* Models */, + CC0100322FF0100000A5BBA3 /* Repository */, + CC0100302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift */, + ); + path = Live; + sourceTree = ""; + }; 038B15E425DE0639156ADBA9 /* Repository */ = { isa = PBXGroup; children = ( @@ -5220,6 +5275,7 @@ CC0100252FF0100000A5BBA3 /* Models */, CC0100262FF0100000A5BBA3 /* Components */, CC0100232FF0100000A5BBA3 /* Home */, + CC0100332FF0100000A5BBA3 /* Live */, CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */, CC0100092FF0100000A5BBA3 /* CreatorChannelViewModel.swift */, ); @@ -5230,6 +5286,7 @@ isa = PBXGroup; children = ( CC0100102FF0100000A5BBA3 /* CreatorChannelTab.swift */, + CC01002A2FF0100000A5BBA3 /* ContentSort.swift */, ); path = Models; sourceTree = ""; @@ -5241,6 +5298,8 @@ CC0100122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift */, CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */, CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */, + CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */, + CC01002C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift */, ); path = Components; sourceTree = ""; @@ -5695,6 +5754,13 @@ CC0101292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */, CC0101092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */, CC0101102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, + CC01012A2FF0100000A5BBA3 /* ContentSort.swift in Sources */, + CC01012B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */, + CC01012C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift in Sources */, + CC01012D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift in Sources */, + CC01012E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */, + CC01012F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */, + CC0101302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift in Sources */, 409E35082B0759DE00CCB985 /* MoyaProviderExtension.swift in Sources */, 40DDBD772B2B4FD700A2E8DA /* CreatorCommunityMoreItemView.swift in Sources */, 400EA7742A89C42C004992CF /* StartLiveRequest.swift in Sources */, @@ -6507,6 +6573,13 @@ CC0102292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */, CC0102092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */, CC0102102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, + CC01022A2FF0100000A5BBA3 /* ContentSort.swift in Sources */, + CC01022B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */, + CC01022C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift in Sources */, + CC01022D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift in Sources */, + CC01022E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */, + CC01022F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */, + CC0102302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift in Sources */, 401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, 401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, @@ -7335,6 +7408,13 @@ CC0103292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */, CC0103092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */, CC0103102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, + CC01032A2FF0100000A5BBA3 /* ContentSort.swift in Sources */, + CC01032B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */, + CC01032C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift in Sources */, + CC01032D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift in Sources */, + CC01032E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */, + CC01032F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */, + CC0103302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift in Sources */, 401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, 401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 8716f70e..1da41569 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -3162,6 +3162,17 @@ If you block this user, the following features will be restricted. } } + enum CreatorChannelLive { + static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") } + enum Sort { + static var latest: String { pick(ko: "최신순", en: "Latest", ja: "最新順") } + static var popular: String { pick(ko: "인기순", en: "Popular", ja: "人気順") } + static var owned: String { pick(ko: "소장중", en: "Owned", ja: "所持中") } + static var priceHigh: String { pick(ko: "높은 가격순", en: "Highest price", ja: "価格が高い順") } + static var priceLow: String { pick(ko: "낮은 가격순", en: "Lowest price", ja: "価格が低い順") } + } + } + enum CreatorChannelHome { static var currentLive: String { pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") } static var latestAudio: String { pick(ko: "최신 오디오 콘텐츠", en: "Latest audio", ja: "最新オーディオ") } diff --git a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift new file mode 100644 index 00000000..80209f49 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct CreatorChannelSortBar: View { + let totalCount: Int + let selectedSort: ContentSort + let onTapSort: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: SodaSpacing.s8) { + HStack(spacing: SodaSpacing.s4) { + Text(I18n.CreatorChannelLive.totalLabel) + .appFont(size: 14, weight: .medium) + .foregroundColor(Color.gray500) + + Text(totalCount.comma()) + .appFont(size: 14, weight: .medium) + .foregroundColor(Color.gray400) + } + + Spacer() + + Button(action: onTapSort) { + HStack(spacing: SodaSpacing.s4) { + Text(selectedSort.title) + .appFont(size: 14, weight: .medium) + .foregroundColor(Color.white) + + Image(systemName: "chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color.gray400) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, SodaSpacing.s20) + .frame(height: 52) + .background(Color.black) + } +} + +struct CreatorChannelSortBar_Previews: PreviewProvider { + static var previews: some View { + CreatorChannelSortBar(totalCount: 100, selectedSort: .latest, onTapSort: {}) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBottomSheet.swift b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBottomSheet.swift new file mode 100644 index 00000000..ad80b1c1 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBottomSheet.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct CreatorChannelSortBottomSheet: View { + let selectedSort: ContentSort + let sorts: [ContentSort] + let onSelect: (ContentSort) -> Void + + init( + selectedSort: ContentSort, + sorts: [ContentSort] = ContentSort.allCases, + onSelect: @escaping (ContentSort) -> Void + ) { + self.selectedSort = selectedSort + self.sorts = sorts + self.onSelect = onSelect + } + + var body: some View { + VStack(spacing: 0) { + ForEach(sorts, id: \.self) { sort in + Button { + onSelect(sort) + } label: { + Text(sort.title) + .appFont(size: 16, weight: selectedSort == sort ? .bold : .medium) + .foregroundColor(selectedSort == sort ? Color.white : Color.gray400) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, SodaSpacing.s20) + .frame(height: 52) + .background(selectedSort == sort ? Color.gray900 : Color.black) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, SodaSpacing.s8) + .background(Color.black) + } +} + +struct CreatorChannelSortBottomSheet_Previews: PreviewProvider { + static var previews: some View { + CreatorChannelSortBottomSheet(selectedSort: .latest, onSelect: { _ in }) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveViewModel.swift b/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveViewModel.swift new file mode 100644 index 00000000..a03cabe0 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveViewModel.swift @@ -0,0 +1,103 @@ +import Foundation +import Combine + +final class CreatorChannelLiveViewModel: ObservableObject { + private let repository = CreatorChannelLiveRepository() + private var subscription = Set() + private let pageSize = 20 + + @Published var isLoading = false + @Published var isLoadingNextPage = false + @Published var response: CreatorChannelLiveTabResponse? + @Published var liveReplayContents = [CreatorChannelAudioContentResponse]() + @Published var selectedSort: ContentSort = .latest + @Published var page = 0 + @Published var size = 20 + @Published var hasNext = false + @Published var hasLoaded = false + @Published var errorMessage = "" + @Published var isShowPopup = false + + func fetchFirstPage(creatorId: Int) { + fetchLive(creatorId: creatorId, page: 0, isNextPage: false) + } + + func fetchNextPageIfNeeded(creatorId: Int, currentItem: CreatorChannelAudioContentResponse) { + guard liveReplayContents.last?.audioContentId == currentItem.audioContentId else { return } + guard hasNext, isLoadingNextPage == false, isLoading == false else { return } + + fetchLive(creatorId: creatorId, page: page + 1, isNextPage: true) + } + + func selectSort(_ sort: ContentSort, creatorId: Int) { + guard selectedSort != sort else { return } + + selectedSort = sort + fetchFirstPage(creatorId: creatorId) + } + + private func fetchLive(creatorId: Int, page requestPage: Int, isNextPage: Bool) { + if isNextPage { + isLoadingNextPage = true + } else { + isLoading = true + } + + repository.getLive(creatorId: creatorId, page: requestPage, size: pageSize, sort: selectedSort) + .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 } + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: response.data) + + if let data = decoded.data, decoded.success { + self.applySuccessState(data, isNextPage: isNextPage) + } 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.hasLoaded = true + self.isLoading = false + self.isLoadingNextPage = false + } + .store(in: &subscription) + } + + private func applySuccessState(_ data: CreatorChannelLiveTabResponse, isNextPage: Bool) { + response = data + page = data.page + size = data.size + hasNext = data.hasNext + + if isNextPage { + liveReplayContents.append(contentsOf: data.liveReplayContents) + } else { + liveReplayContents = data.liveReplayContents + } + } + + private func applyFailureState() { + errorMessage = I18n.Common.commonError + isShowPopup = true + hasLoaded = true + isLoading = false + isLoadingNextPage = false + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Live/Models/CreatorChannelLiveTabResponse.swift b/SodaLive/Sources/V2/CreatorChannel/Live/Models/CreatorChannelLiveTabResponse.swift new file mode 100644 index 00000000..9b915a90 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Live/Models/CreatorChannelLiveTabResponse.swift @@ -0,0 +1,11 @@ +import Foundation + +struct CreatorChannelLiveTabResponse: Decodable { + let liveReplayContentCount: Int + let currentLive: CreatorChannelLiveResponse? + let liveReplayContents: [CreatorChannelAudioContentResponse] + let sort: ContentSort + let page: Int + let size: Int + let hasNext: Bool +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveApi.swift b/SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveApi.swift new file mode 100644 index 00000000..8f6ad23a --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveApi.swift @@ -0,0 +1,41 @@ +import Foundation + +import Moya + +enum CreatorChannelLiveApi { + case getLive(creatorId: Int, page: Int, size: Int, sort: ContentSort) +} + +extension CreatorChannelLiveApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getLive(let creatorId, _, _, _): + return "/api/v2/creator-channels/\(creatorId)/live" + } + } + + var method: Moya.Method { + switch self { + case .getLive: + return .get + } + } + + var task: Moya.Task { + switch self { + case .getLive(_, let page, let size, let sort): + return .requestParameters( + parameters: ["page": page, "size": size, "sort": sort.rawValue], + encoding: URLEncoding.queryString + ) + } + } + + var headers: [String: String]? { + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveRepository.swift b/SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveRepository.swift new file mode 100644 index 00000000..a54fb00f --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveRepository.swift @@ -0,0 +1,13 @@ +import Foundation +import Combine + +import CombineMoya +import Moya + +final class CreatorChannelLiveRepository { + private let api = MoyaProvider() + + func getLive(creatorId: Int, page: Int, size: Int, sort: ContentSort) -> AnyPublisher { + return api.requestPublisher(.getLive(creatorId: creatorId, page: page, size: size, sort: sort)) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Models/ContentSort.swift b/SodaLive/Sources/V2/CreatorChannel/Models/ContentSort.swift new file mode 100644 index 00000000..b01a0b7f --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Models/ContentSort.swift @@ -0,0 +1,24 @@ +import Foundation + +enum ContentSort: String, Decodable, CaseIterable, Hashable { + case latest = "LATEST" + case popular = "POPULAR" + case owned = "OWNED" + case priceHigh = "PRICE_HIGH" + case priceLow = "PRICE_LOW" + + var title: String { + switch self { + case .latest: + return I18n.CreatorChannelLive.Sort.latest + case .popular: + return I18n.CreatorChannelLive.Sort.popular + case .owned: + return I18n.CreatorChannelLive.Sort.owned + case .priceHigh: + return I18n.CreatorChannelLive.Sort.priceHigh + case .priceLow: + return I18n.CreatorChannelLive.Sort.priceLow + } + } +} diff --git a/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md b/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md new file mode 100644 index 00000000..b5ed3855 --- /dev/null +++ b/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md @@ -0,0 +1,308 @@ +# 크리에이터 채널 라이브 탭 구현 계획 + +## 기준 문서 + +- PRD: `docs/20260703_크리에이터_채널_라이브_탭/prd.md` +- 기존 크리에이터 채널 홈 PRD: `docs/20260701_크리에이터_채널_홈/prd.md` +- 기존 크리에이터 채널 홈 구현 계획: `docs/20260701_크리에이터_채널_홈/plan-task.md` +- 본인 채널 플로팅 버튼 PRD: `docs/20260703_크리에이터_채널_본인_진입_플로팅_버튼/prd.md` +- 코드 스타일: `docs/agent-guides/code-style.md` +- 빌드/검증: `docs/agent-guides/build-test-verification.md` +- Figma 전체 화면: `290:8945`, `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-8945&m=dev` +- Figma Sort-bar: `290:8949`, `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-8949&m=dev` +- Figma 라이브 다시듣기 아이템: `290:8954`, `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-8954&m=dev` +- Figma 라이브 다시듣기 아이템 대여/무료 상태: `290:8956`, `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-8956&m=dev` +- Figma 본인 채널 하단 CTA: `665:19359`, `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-19359&m=dev` +- Figma 정렬 컨텍스트 메뉴 예시: `290:9041`, `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-9041&m=dev` + +## 구현 원칙 + +- 기존 `CreatorChannelView`의 공통 shell, header, sticky tab-bar, title bar 동작은 유지한다. +- `CreatorChannelTab.live` 콘텐츠만 신규 `CreatorChannelLiveTabView`로 교체한다. +- 홈 탭의 `CreatorChannelCurrentLiveSection`과 기존 `showLiveDetail(_:)`, `showContentDetail(_:)`, `showCreateLive()` 흐름을 재사용한다. +- `CreatorChannelLiveResponse`와 `CreatorChannelAudioContentResponse`는 기존 `SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift` 타입을 우선 재사용한다. 타입 이동은 이번 구현에 필수일 때만 수행한다. +- `ContentSort`는 라이브 탭 외 콘텐츠 탭에서 재사용 가능하도록 `SodaLive/Sources/V2/CreatorChannel/Models/**` 아래에 둔다. +- sort-bar와 정렬 BottomSheet는 `SodaLive/Sources/V2/CreatorChannel/Components/**` 아래에 둔다. +- 라이브 탭 전용 API, Repository, ViewModel, View, item UI는 `SodaLive/Sources/V2/CreatorChannel/Live/**` 아래에 둔다. +- 최초 요청은 `page = 0`, `size = 20`, `sort = LATEST`로 고정한다. +- `hasNext == true`일 때만 `page + 1` 추가 요청을 수행하고, 추가 요청 중 중복 호출을 막는다. +- 본인 채널 라이브 탭의 하단 CTA는 `ic_new_create_live` asset과 `I18n.CreatorChannelLive.startLive` 문구를 사용한다. +- Figma localhost asset URL은 앱 코드에 사용하지 않는다. + +## 주요 대상 파일 + +### 생성 +- `SodaLive/Sources/V2/CreatorChannel/Models/ContentSort.swift` +- `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift` +- `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBottomSheet.swift` +- `SodaLive/Sources/V2/CreatorChannel/Live/Models/CreatorChannelLiveTabResponse.swift` +- `SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveApi.swift` +- `SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveRepository.swift` +- `SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveViewModel.swift` +- `SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift` +- `SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveReplayListItem.swift` +- `SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveStartButton.swift` + +### 수정 +- `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` +- `SodaLive/Sources/I18n/I18n.swift` +- 필요 시 `SodaLive.xcodeproj/project.pbxproj` + +### 확인 +- `SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift` +- `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelCurrentLiveSection.swift` +- `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelAudioContentListItem.swift` +- `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelFloatingActionMenu.swift` +- `SodaLive/Sources/App/AppStep.swift` +- `SodaLive/Sources/ContentView.swift` +- `SodaLive/Resources/Assets.xcassets/v2/ic_new_create_live.imageset` + +## TASK 체크리스트 + +### Phase 1: PRD 결정 반영과 API 모델 + +- [x] **Task 1.1: 라이브 시작하기 CTA 아이콘 결정 반영 확인** + - 대상 파일: + - 확인: `docs/20260703_크리에이터_채널_라이브_탭/prd.md` + - 확인: `SodaLive/Resources/Assets.xcassets/v2/ic_new_create_live.imageset` + - 작업 내용: + - PRD의 본인 채널 하단 CTA 요구사항, Success Criteria, Decisions에 `ic_new_create_live` 사용이 명시되어 있는지 확인한다. + - asset 경로가 존재하는지 확인한다. + - 검증 기준: + - 실행 명령: `rg "ic_new_create_live|라이브 시작하기" docs/20260703_크리에이터_채널_라이브_탭/prd.md SodaLive/Resources/Assets.xcassets/v2` + - 기대 결과: PRD와 asset 경로에서 `ic_new_create_live`가 확인된다. + +- [x] **Task 1.2: ContentSort enum 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Models/ContentSort.swift` + - 수정: `SodaLive/Sources/I18n/I18n.swift` + - 작업 내용: + - `enum ContentSort: String, Decodable, CaseIterable`을 생성한다. + - case는 `latest = "LATEST"`, `popular = "POPULAR"`, `owned = "OWNED"`, `priceHigh = "PRICE_HIGH"`, `priceLow = "PRICE_LOW"`로 둔다. + - `var title: String`에서 `I18n.CreatorChannelLive.Sort.latest`, `popular`, `owned`, `priceHigh`, `priceLow`를 반환한다. + - `I18n.CreatorChannelLive.Sort`에 ko/en/ja 문구를 추가한다. + - 검증 기준: + - 실행 명령: `rg "enum ContentSort|case latest = \"LATEST\"|case popular = \"POPULAR\"|case owned = \"OWNED\"|case priceHigh = \"PRICE_HIGH\"|case priceLow = \"PRICE_LOW\"|var title" SodaLive/Sources/V2/CreatorChannel/Models/ContentSort.swift` + - 기대 결과: API enum 값 5개와 title mapping이 존재한다. + - 실행 명령: `rg "enum CreatorChannelLive|enum Sort|latest|popular|owned|priceHigh|priceLow" SodaLive/Sources/I18n/I18n.swift` + - 기대 결과: 라이브 탭 정렬 다국어 문구가 존재한다. + +- [x] **Task 1.3: 라이브 탭 Response model 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Live/Models/CreatorChannelLiveTabResponse.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift` + - 작업 내용: + - `CreatorChannelLiveTabResponse`를 `Decodable`로 생성한다. + - 필드는 `liveReplayContentCount`, `currentLive`, `liveReplayContents`, `sort`, `page`, `size`, `hasNext`로 둔다. + - `currentLive`는 기존 `CreatorChannelLiveResponse?`, `liveReplayContents`는 기존 `[CreatorChannelAudioContentResponse]`를 사용한다. + - 검증 기준: + - 실행 명령: `rg "struct CreatorChannelLiveTabResponse|liveReplayContentCount|currentLive: CreatorChannelLiveResponse\\?|liveReplayContents: \\[CreatorChannelAudioContentResponse\\]|sort: ContentSort|page: Int|size: Int|hasNext: Bool" SodaLive/Sources/V2/CreatorChannel/Live/Models/CreatorChannelLiveTabResponse.swift` + - 기대 결과: PRD 기준 응답 필드가 모두 존재한다. + +### Phase 2: API, Repository, ViewModel + +- [x] **Task 2.1: 라이브 탭 API endpoint 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveApi.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Home/Repository/CreatorChannelHomeApi.swift` + - 작업 내용: + - `CreatorChannelLiveApi`를 `TargetType`으로 생성한다. + - case는 `getLive(creatorId: Int, page: Int, size: Int, sort: ContentSort)`로 둔다. + - path는 `/api/v2/creator-channels/{creatorId}/live`로 구성한다. + - method는 `.get`으로 둔다. + - task는 `.requestParameters(parameters: ["page": page, "size": size, "sort": sort.rawValue], encoding: URLEncoding.queryString)`로 둔다. + - headers는 기존 V2 API와 동일하게 `Authorization: Bearer {token}` 패턴을 따른다. + - 검증 기준: + - 실행 명령: `rg "enum CreatorChannelLiveApi|getLive\\(creatorId: Int, page: Int, size: Int, sort: ContentSort\\)|/api/v2/creator-channels/|/live|requestParameters|URLEncoding.queryString|sort.rawValue|Authorization" SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveApi.swift` + - 기대 결과: live endpoint, query parameters, 인증 헤더가 확인된다. + +- [x] **Task 2.2: 라이브 탭 Repository 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveRepository.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Home/Repository/CreatorChannelHomeRepository.swift` + - 작업 내용: + - `CreatorChannelLiveRepository`를 만들고 `MoyaProvider`를 사용한다. + - `getLive(creatorId:page:size:sort:) -> AnyPublisher` 메서드를 제공한다. + - 검증 기준: + - 실행 명령: `rg "final class CreatorChannelLiveRepository|MoyaProvider|getLive\\(creatorId: Int, page: Int, size: Int, sort: ContentSort\\)|AnyPublisher" SodaLive/Sources/V2/CreatorChannel/Live/Repository/CreatorChannelLiveRepository.swift` + - 기대 결과: 기존 V2 Repository와 같은 형태가 확인된다. + +- [x] **Task 2.3: 라이브 탭 ViewModel 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveViewModel.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift` + - 확인: `SodaLive/Sources/Content/All/ContentAllViewModel.swift` + - 작업 내용: + - `CreatorChannelLiveViewModel`을 `ObservableObject`로 생성한다. + - 상태는 `isLoading`, `isLoadingNextPage`, `response`, `liveReplayContents`, `selectedSort`, `page`, `size`, `hasNext`, `hasLoaded`, `errorMessage`, `isShowPopup`를 둔다. + - `size` 기본값은 `20`, `selectedSort` 기본값은 `.latest`, 최초 `page`는 `0`으로 둔다. + - `fetchFirstPage(creatorId:)`는 `page = 0` 첫 페이지를 조회하되 요청 전 기존 목록과 pagination 상태를 비우지 않는다. + - `fetchNextPageIfNeeded(creatorId:currentItem:)`는 마지막 item 노출, `hasNext == true`, 추가 로딩 아님 조건에서 `page + 1`을 조회한다. + - `selectSort(_:creatorId:)`는 sort가 바뀔 때만 `selectedSort`를 갱신하고 첫 페이지를 다시 조회한다. + - 첫 페이지 성공 시 `response`, `page`, `size`, `hasNext`를 갱신하고 `liveReplayContents`를 조회 결과로 교체한다. 다음 페이지 성공 시 같은 상태를 갱신하고 `liveReplayContents` 뒤에 append한다. + - 첫 페이지 실패와 다음 페이지 실패 모두 기존 목록과 pagination 상태를 유지하고 `errorMessage`, `isShowPopup`를 갱신한다. + - 검증 기준: + - 실행 명령: `rg "final class CreatorChannelLiveViewModel|isLoadingNextPage|liveReplayContents|selectedSort|private let pageSize|fetchFirstPage\\(creatorId: Int\\)|fetchNextPageIfNeeded|selectSort|ApiResponse|page \\+ 1|hasNext" SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveViewModel.swift` + - 기대 결과: 첫 페이지 로딩, 정렬 변경, 다음 페이지 로딩 상태와 성공 시에만 목록 교체/append하는 동작이 확인된다. + +### Phase 3: sort-bar와 정렬 BottomSheet + +- [x] **Task 3.1: sort-bar 공통 컴포넌트 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift` + - 수정: `SodaLive/Sources/I18n/I18n.swift` + - 작업 내용: + - `CreatorChannelSortBar`를 `View`로 생성한다. + - 입력값은 `totalCount: Int`, `selectedSort: ContentSort`, `onTapSort: () -> Void`로 둔다. + - 좌측에는 `I18n.CreatorChannelLive.totalLabel`과 `totalCount`를 표시한다. + - 우측에는 `selectedSort.title`과 정렬 아이콘을 표시한다. + - 높이는 Figma 기준 `52pt`로 둔다. + - 배경은 black, 텍스트는 기존 `Color.gray400`, `Color.gray500`, white 토큰을 따른다. + - 검증 기준: + - 실행 명령: `rg "struct CreatorChannelSortBar|totalCount: Int|selectedSort: ContentSort|onTapSort|I18n.CreatorChannelLive.totalLabel|selectedSort.title|frame\\(height: 52" SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift` + - 기대 결과: 전체 개수, 현재 정렬, 터치 action이 있는 sort-bar가 확인된다. + +- [x] **Task 3.2: 정렬 BottomSheet 컴포넌트 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBottomSheet.swift` + - 작업 내용: + - `CreatorChannelSortBottomSheet`를 `View`로 생성한다. + - 입력값은 `selectedSort: ContentSort`, `sorts: [ContentSort] = ContentSort.allCases`, `onSelect: (ContentSort) -> Void`로 둔다. + - 항목은 `LATEST`, `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW` 순서로 표시한다. + - Figma context menu의 `추천순`은 표시하지 않는다. + - 선택된 항목은 배경색 또는 체크 아이콘 없이도 충분히 구분되는 foreground/background 상태를 적용한다. + - 검증 기준: + - 실행 명령: `rg "struct CreatorChannelSortBottomSheet|selectedSort: ContentSort|ContentSort.allCases|onSelect|ForEach|sort.title" SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBottomSheet.swift` + - 기대 결과: ContentSort 5개 항목 기반 BottomSheet가 확인된다. + - 실행 명령: `rg "추천순|recommend" SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBottomSheet.swift` + - 기대 결과: 검색 결과가 없다. + +### Phase 4: 라이브 탭 UI + +- [ ] **Task 4.1: 라이브 다시듣기 item 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveReplayListItem.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelAudioContentListItem.swift` + - 확인: `SodaLive/Sources/I18n/I18n.swift` + - 작업 내용: + - `CreatorChannelLiveReplayListItem`을 `View`로 생성한다. + - 입력값은 `audioContent: CreatorChannelAudioContentResponse`, `action: () -> Void`로 둔다. + - 88pt 썸네일, 제목, duration, 우측 가격/상태 영역을 구성한다. + - `price == 0`이면 무료 태그를 표시한다. + - `isPointAvailable == true`이면 point 태그 또는 point 가격 표시를 적용한다. + - `isAdult == true`이면 19금 shield 태그를 표시한다. + - `isOwned == true`이면 우측 상태 영역에 play button과 `I18n.Content.Status.owned` 또는 신규 `I18n.CreatorChannelLive.owned` 문구를 표시한다. + - `isRented == true`이고 `isOwned == false`이면 play button과 `I18n.Content.Status.rented` 또는 신규 `I18n.CreatorChannelLive.rented` 문구를 표시한다. + - `isOwned == true && isRented == true`이면 `소장중`을 우선 표시한다. + - tap 시 `action()`을 호출한다. + - 검증 기준: + - 실행 명령: `rg "struct CreatorChannelLiveReplayListItem|CreatorChannelAudioContentResponse|audioContent.isOwned|audioContent.isRented|audioContent.isAdult|audioContent.isPointAvailable|audioContent.price == 0|소장중|대여중|action\\(\\)" SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveReplayListItem.swift` + - 기대 결과: 다시듣기 item의 상태 표시 조건과 tap action이 확인된다. + +- [ ] **Task 4.2: 라이브 시작하기 하단 CTA 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveStartButton.swift` + - 수정: `SodaLive/Sources/I18n/I18n.swift` + - 확인: `SodaLive/Resources/Assets.xcassets/v2/ic_new_create_live.imageset` + - 작업 내용: + - `CreatorChannelLiveStartButton`을 `View`로 생성한다. + - 입력값은 `action: () -> Void`로 둔다. + - full-width capsule button으로 표시한다. + - 아이콘은 `Image("ic_new_create_live")`를 사용한다. + - 문구는 `I18n.CreatorChannelLive.startLive`를 사용한다. + - safe area bottom과 겹치지 않도록 부모 view에서 bottom padding을 줄 수 있게 고정 높이를 안정적으로 유지한다. + - 검증 기준: + - 실행 명령: `rg "struct CreatorChannelLiveStartButton|ic_new_create_live|I18n.CreatorChannelLive.startLive|Button\\(action: action\\)|Capsule|frame\\(maxWidth: \\.infinity" SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveStartButton.swift` + - 기대 결과: 지정 아이콘과 문구를 사용하는 CTA 컴포넌트가 확인된다. + +- [ ] **Task 4.3: 라이브 탭 화면 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelCurrentLiveSection.swift` + - 작업 내용: + - `CreatorChannelLiveTabView`를 `View`로 생성한다. + - 입력값은 `creatorId: Int`, `isOwnCreatorChannel: Bool`, `onTapLive: (Int) -> Void`, `onTapContent: (Int) -> Void`, `onTapCreateLive: () -> Void`로 둔다. + - 내부에서 `@StateObject private var viewModel = CreatorChannelLiveViewModel()`을 사용한다. + - `onAppear`에서 `hasLoaded == false`이면 `fetchFirstPage(creatorId:)`를 호출한다. + - sort-bar 터치 시 `.sheet` 또는 기존 앱 sheet 패턴으로 `CreatorChannelSortBottomSheet`를 표시한다. + - `currentLive != nil`이면 `CreatorChannelCurrentLiveSection(currentLive:onTapLive:)`를 표시한다. + - `liveReplayContents`는 `LazyVStack`으로 표시하고 각 item의 `onAppear`에서 `fetchNextPageIfNeeded`를 호출한다. + - item tap은 `onTapContent(audioContent.audioContentId)`로 연결한다. + - `isOwnCreatorChannel == true`이면 하단 overlay 또는 safe area inset에 `CreatorChannelLiveStartButton(action: onTapCreateLive)`를 표시한다. + - CTA가 목록을 가리지 않도록 scroll content bottom padding을 추가한다. + - 검증 기준: + - 실행 명령: `rg "struct CreatorChannelLiveTabView|CreatorChannelLiveViewModel|fetchFirstPage\\(creatorId: creatorId\\)|CreatorChannelSortBar|CreatorChannelSortBottomSheet|CreatorChannelCurrentLiveSection|CreatorChannelLiveReplayListItem|fetchNextPageIfNeeded|onTapContent\\(audioContent.audioContentId\\)|CreatorChannelLiveStartButton|isOwnCreatorChannel" SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift` + - 기대 결과: 라이브 탭 화면, 정렬, 현재 라이브, 다시듣기 목록, pagination, 본인 CTA가 확인된다. + +### Phase 5: CreatorChannelView 연결 + +- [ ] **Task 5.1: 라이브 탭 콘텐츠 연결** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` + - 작업 내용: + - `selectedTabContent`에서 `viewModel.selectedTab == .live`일 때 `CreatorChannelLiveTabView`를 표시한다. + - `creatorId`, `isOwnCreatorChannel`, `showLiveDetail`, `showContentDetail`, `showCreateLive`를 전달한다. + - 기존 홈 탭 분기는 유지한다. + - 홈 외 준비 중 화면 분기는 `live`를 제외한 탭에만 적용한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelLiveTabView|viewModel\\.selectedTab == \\.live|isOwnCreatorChannel|onTapLive: showLiveDetail|onTapContent: showContentDetail|onTapCreateLive: showCreateLive" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` + - 기대 결과: 라이브 탭이 준비 중 화면이 아니라 신규 view로 연결된다. + +- [ ] **Task 5.2: 본인 채널 홈 플로팅 메뉴와 라이브 CTA 조건 분리 확인** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelFloatingActionMenu.swift` + - 작업 내용: + - 기존 `CreatorChannelFloatingActionMenu`는 본인 채널 `home` 탭에서만 유지한다. + - 라이브 탭에서는 `CreatorChannelLiveTabView` 내부 하단 CTA만 표시되도록 한다. + - tab 변경 시 기존 `isCreatorActionMenuPresented = false` 동작은 유지한다. + - 검증 기준: + - 실행 명령: `rg "isOwnCreatorChannel && viewModel\\.selectedTab == \\.home|isCreatorActionMenuPresented = false|CreatorChannelLiveTabView" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` + - 기대 결과: 홈 floating menu와 라이브 하단 CTA 표시 조건이 분리되어 있다. + +### Phase 6: 검증 + +- [ ] **Task 6.1: 정적 검색 검증** + - 대상 파일: + - 확인: `SodaLive/Sources/V2/CreatorChannel` + - 확인: `SodaLive/Sources/I18n/I18n.swift` + - 작업 내용: + - API endpoint, query parameter, 정렬 enum, `page=0`, `size=20`, sort-bar, BottomSheet, current live 재사용, 다시듣기 item, `ic_new_create_live`, 오디오 상세 이동 연결을 검색한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelLiveApi|/api/v2/creator-channels/|/live|URLEncoding.queryString|ContentSort|pageSize|20|page = 0|CreatorChannelSortBar|CreatorChannelSortBottomSheet|CreatorChannelCurrentLiveSection|CreatorChannelLiveReplayListItem|ic_new_create_live|contentDetail\\(contentId:" SodaLive/Sources/V2/CreatorChannel SodaLive/Sources/I18n/I18n.swift` + - 기대 결과: PRD 핵심 구현 지점이 모두 검색된다. + +- [ ] **Task 6.2: 빌드 검증** + - 대상 파일: + - 확인: `SodaLive.xcworkspace` + - 작업 내용: + - Debug 빌드를 실행한다. + - 검증 기준: + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 기대 결과: `** BUILD SUCCEEDED **` + +- [ ] **Task 6.3: 수동 동작 검증** + - 대상 화면: + - 타인 크리에이터 채널 라이브 탭 + - 본인 크리에이터 채널 라이브 탭 + - 작업 내용: + - 라이브 탭 진입 시 첫 요청이 `page=0`, `size=20`, `sort=LATEST`로 호출되는지 확인한다. + - sort-bar에 전체 개수와 `최신순`이 표시되는지 확인한다. + - sort-bar 터치 시 BottomSheet가 표시되고 5개 정렬만 보이는지 확인한다. + - 정렬 변경 시 첫 페이지부터 다시 조회되는지 확인한다. + - `currentLive != nil`인 경우 홈 탭 현재 라이브 UI와 동일한 카드가 표시되는지 확인한다. + - 마지막 item 노출 시 `hasNext == true`이면 `page + 1`이 호출되고 목록이 append되는지 확인한다. + - `hasNext == false`이면 추가 호출이 없는지 확인한다. + - 다시듣기 item의 무료, 가격, 포인트, 19금, 소장중, 대여중 상태가 조건에 맞게 표시되는지 확인한다. + - 다시듣기 item tap 시 오디오 상세 화면으로 이동하는지 확인한다. + - 본인 채널 라이브 탭에서 `ic_new_create_live` 아이콘이 포함된 `라이브 시작하기` 버튼이 하단에 표시되는지 확인한다. + - 타인 채널 또는 라이브 외 탭에서는 `라이브 시작하기` 버튼이 표시되지 않는지 확인한다. + - 검증 기준: + - 기대 결과: PRD Success Criteria를 모두 만족한다. + +## 검증 기록 + +- 2026-07-03: 문서 작성 단계. 구현/빌드 검증은 아직 수행하지 않음. +- 2026-07-03: Phase 1~3 구현 완료. `rg` 기반 정적 검증, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, `xcodebuild -workspace "SodaLive.xcworkspace" -list`를 확인함. +- 2026-07-03: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`는 실행했으나, DerivedData `manifest.json` 작성 중 디스크 용량 부족(`No space left on device`, 여유 598Mi)으로 Swift 컴파일 전 실패함. +- 2026-07-03: Phase 2.3 보완. 첫 페이지/정렬 변경/다음 페이지 조회 모두 성공 시에만 목록 교체 또는 append를 수행하고, 실패 시 기존 목록과 pagination 상태를 유지하도록 PRD/계획/구현을 정렬함. diff --git a/docs/20260703_크리에이터_채널_라이브_탭/prd.md b/docs/20260703_크리에이터_채널_라이브_탭/prd.md new file mode 100644 index 00000000..5ce62cfb --- /dev/null +++ b/docs/20260703_크리에이터_채널_라이브_탭/prd.md @@ -0,0 +1,258 @@ +# PRD: 크리에이터 채널 라이브 탭 + +## 1. Overview +크리에이터 채널 공통 shell의 `라이브` 탭에서 현재 진행 중인 라이브와 라이브 다시듣기 목록을 제공한다. 화면 상단의 title bar, 크리에이터 프로필 header, tab-bar는 기존 크리에이터 채널 홈 구현을 재사용하고, tab-bar 아래 콘텐츠만 라이브 탭 전용 API 응답으로 교체한다. + +라이브 탭은 `GET /api/v2/creator-channels/{creatorId}/live` 응답을 사용한다. 정렬 기본값은 `LATEST`이며, 사용자는 sort-bar에서 정렬을 변경할 수 있다. `CreatorChannelLiveTabResponse.hasNext == true`이면 현재 응답의 `page + 1` 페이지를 추가로 조회해 스크롤 로딩한다. + +Figma 참조: +- 전체 화면: `290:8945`, `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-8945&m=dev` +- Sort-bar: `290:8949`, `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-8949&m=dev` +- 라이브 다시듣기 아이템: `290:8954`, `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-8954&m=dev` +- 라이브 다시듣기 아이템 대여/무료 상태: `290:8956`, `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-8956&m=dev` +- 본인 채널 하단 CTA: `665:19359`, `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-19359&m=dev` +- 정렬 컨텍스트 메뉴 예시: `290:9041`, `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-9041&m=dev` + +추후 구현 계획 문서(`plan-task.md`)를 작성할 때 위 Figma URL을 기준 문서 섹션에 모두 포함한다. + +## 2. Problem +- 현재 크리에이터 채널 공통 shell은 홈 탭 중심으로 구현되어 있고, `라이브` 탭의 실제 목록 API, 정렬, 페이지네이션이 연결되어 있지 않다. +- 라이브 탭은 홈 탭의 현재 진행 중인 라이브 UI를 재사용해야 하지만, 라이브 다시듣기 목록은 가격, 포인트 사용 가능, 19금, 소장중, 대여중 상태를 함께 표시해야 하므로 별도 item 요구사항이 필요하다. +- sort-bar는 라이브 탭 전용이 아니라 다른 탭에서도 같은 형태로 재사용될 수 있어 채널 공통 컴포넌트 경계가 필요하다. +- 정렬 선택 UI는 Figma에 anchored context menu 형태가 있으나, 모바일 하단 접근성과 옵션 확장성을 고려해 앱 구현 방식 결정이 필요하다. +- 본인 채널의 라이브 탭에서는 하단에 고정된 `라이브 시작하기` CTA가 필요하며, 일반 사용자 채널 진입과 표시 조건이 달라야 한다. + +## 3. Goals +- `CreatorChannelTab.live` 선택 시 라이브 탭 API를 호출하고 응답 데이터로 화면을 구성한다. +- `GET /api/v2/creator-channels/{creatorId}/live`에 `page`, `size`, `sort` query parameter를 전달한다. +- 기본 정렬은 `LATEST`로 호출하고, 서버 응답의 `sort`를 현재 선택 정렬 상태로 반영한다. +- sort-bar는 `SodaLive/Sources/V2/CreatorChannel/Components/**` 아래에 생성해 라이브 탭 외 다른 탭에서도 재사용할 수 있게 한다. +- 정렬 문구는 `I18n`에 ko/en/ja 다국어로 추가한다. +- 정렬 선택 UI는 BottomSheet 방식을 채택한다. +- 현재 진행 중인 라이브는 크리에이터 채널 홈의 `CreatorChannelCurrentLiveSection` UI를 재사용한다. +- 라이브 다시듣기 item은 Figma 기준으로 썸네일, 제목, duration, 가격/포인트/무료/19금/소장중/대여중 상태를 표시한다. +- `hasNext == true`이면 스크롤 하단 진입 시 현재 응답의 `page + 1` 페이지를 추가 조회한다. +- 본인 채널이고 선택 탭이 `라이브`일 때 하단 고정 `라이브 시작하기` 버튼을 표시한다. + +## 4. Non-Goals +- 크리에이터 채널 공통 shell, header, sticky tab-bar 동작을 다시 설계하지 않는다. +- 홈 탭의 현재 라이브 UI를 새로 만들지 않는다. +- 라이브룸 또는 라이브 생성 화면 자체를 새로 구현하지 않는다. 기존 라이브 시작/생성 라우팅이 있으면 재사용한다. +- 다시듣기 구매, 대여, 재생 권한 mutation을 새로 구현하지 않는다. item 표시와 기존 상세/재생 진입 연결까지만 범위로 본다. +- API enum에 없는 `추천순` 정렬은 구현하지 않는다. +- Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다. +- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다. + +## 5. Target Users +- 크리에이터 채널에서 진행 중인 라이브와 라이브 다시듣기를 탐색하려는 사용자 +- 가격, 포인트 사용 가능 여부, 소장/대여 상태를 보고 다시듣기 콘텐츠를 선택하려는 사용자 +- 자신의 채널 라이브 탭에서 새 라이브를 시작하려는 크리에이터 + +## 6. User Stories +- 사용자는 크리에이터 채널 라이브 탭에서 현재 진행 중인 라이브가 있으면 상단에서 바로 확인하고 싶다. +- 사용자는 라이브 다시듣기 전체 개수와 현재 정렬 기준을 확인하고 싶다. +- 사용자는 최신순, 인기순, 소장순, 가격 높은 순, 가격 낮은 순으로 다시듣기 목록을 정렬하고 싶다. +- 사용자는 목록을 아래로 스크롤하면 다음 페이지가 자동으로 이어서 로드되기를 기대한다. +- 사용자는 다시듣기 item에서 무료, 가격, 포인트 사용 가능, 19금, 소장중, 대여중 상태를 구분하고 싶다. +- 크리에이터는 본인 채널의 라이브 탭에서 하단 고정 버튼으로 라이브 시작 화면에 빠르게 진입하고 싶다. + +## 7. Core Requirements + +### 7.1 API +- Method: `GET` +- Path: `/api/v2/creator-channels/{creatorId}/live` +- Path parameter: `creatorId` +- Query parameters: + - `page` + - `size` + - `sort` +- 인증: 기존 V2 API 인증 헤더 패턴을 따른다. +- 응답 래퍼: 기존 관례대로 `ApiResponse` 디코딩을 우선한다. + +#### Response 기준 +```kotlin +data class CreatorChannelLiveTabResponse( + val liveReplayContentCount: Int, + val currentLive: CreatorChannelLiveResponse?, + val liveReplayContents: List, + val sort: ContentSort, + val page: Int, + val size: Int, + val hasNext: Boolean +) + +data class CreatorChannelAudioContentResponse( + val audioContentId: Long, + val title: String, + val duration: String?, + val imageUrl: String?, + val price: Int, + val isAdult: Boolean, + val isPointAvailable: Boolean, + val isFirstContent: Boolean, + val seriesName: String?, + val isOriginalSeries: Boolean?, + val isOwned: Boolean, + val isRented: Boolean +) + +enum class ContentSort { + LATEST, + POPULAR, + OWNED, + PRICE_HIGH, + PRICE_LOW +} +``` + +#### Swift 모델 기준 +```swift +struct CreatorChannelLiveTabResponse: Decodable { + let liveReplayContentCount: Int + let currentLive: CreatorChannelLiveResponse? + let liveReplayContents: [CreatorChannelAudioContentResponse] + let sort: ContentSort + let page: Int + let size: Int + let hasNext: Bool +} + +enum ContentSort: String, Decodable, CaseIterable { + case latest = "LATEST" + case popular = "POPULAR" + case owned = "OWNED" + case priceHigh = "PRICE_HIGH" + case priceLow = "PRICE_LOW" +} +``` + +- `CreatorChannelLiveResponse`와 `CreatorChannelAudioContentResponse`는 기존 `SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift`의 모델을 우선 재사용한다. +- `audioContentId` 같은 Kotlin `Long` 값은 기존 V2 모델 관례대로 Swift `Int`로 선언한다. +- `ContentSort`는 라이브 탭뿐 아니라 다른 콘텐츠 탭에서도 재사용될 수 있으므로 계획 단계에서 공용 위치를 정한다. + +### 7.2 라이브 탭 상태 +- `CreatorChannelViewModel` 또는 라이브 탭 전용 ViewModel은 선택 탭이 `live`가 될 때 라이브 탭 데이터를 조회한다. +- 동일 화면 생명주기에서 같은 `creatorId`, `sort`, 첫 페이지 데이터가 이미 있으면 불필요한 중복 호출을 피한다. +- 정렬 변경 시 첫 페이지부터 다시 조회하고, 조회 성공 시에만 기존 `liveReplayContents`를 새 정렬 결과로 교체한다. +- API 실패 시 크리에이터 채널 공통 shell은 유지하고, 이미 표시 중인 `liveReplayContents`는 유지한 채 기존 V2 패턴의 오류/빈 상태를 표시한다. +- `liveReplayContentCount == 0`이고 `currentLive == nil`이면 sort-bar의 전체 개수는 `0`으로 표시하고 목록 empty state를 표시한다. + +### 7.3 Sort-bar +- 생성 위치: `SodaLive/Sources/V2/CreatorChannel/Components/**` +- sort-bar는 좌측에 전체 개수, 우측에 현재 정렬명과 아래 방향 아이콘을 표시한다. +- Figma 기준 높이는 `52pt`, 좌우 horizontal padding은 기존 `SodaSpacing` 토큰으로 맞춘다. +- 전체 개수는 `liveReplayContentCount`를 사용한다. +- 문구 `전체`, 정렬명은 `I18n`에 추가한다. +- sort-bar는 다른 탭에서도 재사용 가능하도록 `totalCount`, `selectedSort`, `availableSorts`, `onTapSort` 같은 일반화된 입력을 받는다. +- 이번 범위에서는 Figma의 `리스트형` view mode 옵션은 사용하지 않는다. + +### 7.4 정렬 선택 UI +- 정렬 선택 UI는 BottomSheet 방식으로 구현한다. +- 선택 사유: + - 모바일 한 손 조작에서 화면 하단 sheet가 우측 상단 anchored menu보다 터치 접근성이 좋다. + - 정렬 옵션이 5개이고 이후 탭별 옵션 추가 가능성이 있어 sheet가 선택 영역과 dismiss 동작을 안정적으로 제공한다. + - iOS/Material 계열 최신 모바일 앱에서 필터/정렬 같은 목록 제어는 sheet 패턴이 일반적이며, context menu는 짧은 보조 action에 더 적합하다. +- 정렬 sheet 항목: + - `LATEST`: 최신순 + - `POPULAR`: 인기순 + - `OWNED`: 소장순 + - `PRICE_HIGH`: 가격 높은 순 + - `PRICE_LOW`: 가격 낮은 순 +- Figma `290:9041`에는 `추천순`이 있으나 API `ContentSort`에 없으므로 구현하지 않는다. +- 현재 선택된 sort는 시각적으로 선택 상태를 표시한다. +- sort item 터치 시 sheet를 닫고 첫 페이지를 재조회한다. + +### 7.5 현재 진행 중인 라이브 +- `currentLive != nil`이면 sort-bar 아래 또는 Figma 배치 기준 위치에 현재 진행 중인 라이브 UI를 표시한다. +- UI는 홈 탭의 `CreatorChannelCurrentLiveSection` / `CreatorChannelCurrentLiveCard`를 재사용한다. +- `currentLive == nil`이면 현재 라이브 영역은 숨긴다. +- 현재 라이브 card tap은 기존 홈 탭과 동일한 라이브 상세/라이브룸 진입 흐름을 사용한다. + +### 7.6 라이브 다시듣기 item +- 목록 데이터는 `liveReplayContents`를 사용한다. +- item은 Figma 기준으로 가로 row 형태를 사용한다. +- 표시 필드: + - `imageUrl`: 88pt 정사각 썸네일, corner radius는 기존 컴포넌트/Figma 기준을 따른다. + - `title`: 1줄 또는 Figma 상태처럼 최대 2줄까지 표시하고 영역 초과 시 말줄임 처리한다. + - `duration`: 값이 있으면 subtitle로 표시한다. + - `price`: `0`이면 무료 태그, `0`보다 크면 can 가격 표시 + - `isPointAvailable`: point 태그 또는 point 가격 표시 + - `isAdult`: 19금 shield 태그 표시 + - `isFirstContent`: 첫 회차 태그 표시 + - `seriesName`: 필요 시 duration 보조 정보로 표시하되 Figma 라이브 다시듣기 item 우선순위를 해치지 않는다. + - `isOriginalSeries`: original series 태그 표시 + - `isOwned`: 우측 상태 영역에 소장중 표시 + - `isRented`: 우측 상태 영역에 대여중 표시 +- `isOwned == true` 또는 `isRented == true`이면 가격 대신 play button과 상태 텍스트를 우선 표시한다. +- `isOwned == true`와 `isRented == true`가 동시에 내려오면 `소장중` 표시를 우선한다. +- item tap은 오디오 상세 화면으로 이동한다. + +### 7.7 스크롤 로딩 +- 최초 요청은 `page = 0`, `size = 20`, 기본 sort `LATEST`로 첫 페이지를 조회한다. +- 스크롤 하단 또는 마지막 item 노출 시점에 `hasNext == true`이고 추가 로딩 중이 아니면 다음 페이지를 요청한다. +- 다음 페이지 query의 `page`는 현재 응답의 `page + 1`을 사용한다. +- 다음 페이지 query의 `size`는 `20`으로 유지한다. +- 첫 페이지 조회 성공 시 기존 `liveReplayContents`를 조회 결과로 교체한다. +- 첫 페이지 조회 실패 시 기존 목록과 pagination 상태는 유지하고, 필요하면 기존 토스트/오류 표시 패턴을 따른다. +- 추가 조회 성공 시 기존 `liveReplayContents` 뒤에 새 page의 목록을 append한다. +- 추가 조회 실패 시 기존 목록과 pagination 상태는 유지하고, 필요하면 기존 토스트/오류 표시 패턴을 따른다. +- sort 변경 중이거나 첫 페이지 로딩 중에는 pagination 요청을 중복 실행하지 않는다. + +### 7.8 본인 채널 하단 라이브 시작하기 CTA +- 본인 채널 여부는 기존 `docs/20260703_크리에이터_채널_본인_진입_플로팅_버튼/prd.md`의 판별 기준을 재사용한다. +- 본인 채널이고 `selectedTab == .live`일 때만 하단 고정 `라이브 시작하기` 버튼을 표시한다. +- `라이브 시작하기` 버튼 아이콘은 `ic_new_create_live` asset을 사용한다. +- CTA는 화면 하단에 고정하고 safe area bottom을 고려한다. +- CTA가 목록 마지막 item을 가리지 않도록 라이브 탭 scroll content에 bottom inset/padding을 추가한다. +- 버튼 터치 시 기존 라이브 생성/시작 라우팅을 재사용한다. +- Figma node `665:19359`의 버튼 텍스트는 현재 `후원하기`로 확인되지만, 사용자 요구사항이 `라이브 시작하기`이므로 구현 문구와 액션은 `라이브 시작하기`를 기준으로 한다. + +## 8. UX / UI Expectations +- 크리에이터 채널 공통 header, sticky tab-bar, title bar background progress 동작은 홈 탭과 동일하게 유지한다. +- 라이브 탭 선택 시 tab-bar indicator는 기존 `CreatorChannelTabBar`의 선택 상태를 따른다. +- 라이브 탭 콘텐츠 배경은 기존 크리에이터 채널 dark theme와 맞춘다. +- sort-bar는 tab-bar 아래에 붙어 보이고, 목록 콘텐츠와 겹치지 않아야 한다. +- 다시듣기 item의 썸네일, 제목, 가격/상태 영역은 긴 제목과 다국어 문구에서도 겹치지 않아야 한다. +- BottomSheet는 dim 또는 drag/dismiss 동작이 기존 앱 sheet 패턴과 일관되어야 한다. +- 로딩 중에는 기존 목록을 과도하게 흔들지 않고, 첫 페이지 로딩과 추가 페이지 로딩 상태를 구분한다. 목록 교체와 append는 조회 성공 시에만 수행한다. + +## 9. Technical Constraints +- 기능 변경은 `SodaLive/Sources/V2/CreatorChannel/**` 하위에서 해결한다. +- 재사용 가능한 sort-bar는 `SodaLive/Sources/V2/CreatorChannel/Components/**` 아래에 둔다. +- 라이브 탭 전용 View, ViewModel, Repository, API, 모델은 `SodaLive/Sources/V2/CreatorChannel/Live/**` 아래에 둔다. +- 현재 라이브 UI는 `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelCurrentLiveSection.swift`를 재사용한다. +- 기존 `CreatorChannelAudioContentResponse`를 재사용하되, 모델 위치가 홈 탭에 종속되어 유지보수성이 떨어지면 계획 단계에서 `SodaLive/Sources/V2/CreatorChannel/Models/**`로 이동을 검토한다. +- 신규 사용자 노출 문구는 `SodaLive/Sources/I18n/I18n.swift`에 ko/en/ja를 추가한다. +- API 구현은 기존 V2 `TargetType`, Repository, `ApiResponse` 디코딩 패턴을 따른다. +- Figma의 localhost asset URL은 코드에 넣지 않고 기존 asset 또는 기존 태그 컴포넌트를 사용한다. + +## 10. Success Criteria +- 라이브 탭 선택 시 `GET /api/v2/creator-channels/{creatorId}/live?page={page}&size={size}&sort={sort}`가 호출된다. +- 기본 sort는 `LATEST`이며 sort-bar에는 `최신순`이 표시된다. +- 최초 요청은 `page=0`, `size=20`으로 호출된다. +- sort-bar 좌측에 `liveReplayContentCount` 기준 전체 개수가 표시된다. +- sort-bar 터치 시 BottomSheet가 표시되고 `LATEST`, `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW` 5개 옵션만 보인다. +- 정렬 변경 시 첫 페이지부터 다시 조회되고, 조회 성공 시에만 목록이 새 정렬 결과로 교체된다. +- `currentLive != nil`이면 홈 탭과 동일한 현재 라이브 UI가 라이브 탭에 표시된다. +- 라이브 다시듣기 item에 가격, 포인트 사용 가능, 19금, 소장중, 대여중 상태가 조건에 맞게 표시된다. +- `hasNext == true` 상태에서 하단 스크롤 시 `page + 1` 페이지가 호출되고, 조회 성공 시에만 기존 목록 뒤에 append된다. +- `hasNext == false`이면 추가 페이지 호출을 하지 않는다. +- 본인 채널의 라이브 탭 하단에는 `라이브 시작하기` 고정 버튼이 표시된다. +- `라이브 시작하기` 고정 버튼에는 `ic_new_create_live` 아이콘이 표시된다. +- 타인 채널 또는 라이브 외 탭에서는 `라이브 시작하기` 버튼이 표시되지 않는다. +- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`가 성공한다. + +## 11. Decisions +- 정렬 메뉴 표시는 BottomSheet 방식을 채택한다. +- 첫 페이지의 `page` 시작값은 `0`으로 한다. +- 요청 `size` 기본값은 `20`으로 한다. +- Figma 정렬 메뉴 예시에 있는 `추천순`은 API enum에 없으므로 제외한다. +- Sort-bar는 라이브 탭 전용 위치가 아니라 `V2/CreatorChannel/Components/**`에 둔다. +- 현재 진행 중인 라이브 UI는 홈 탭 구현을 재사용한다. +- `isOwned == true`일 때 표시 문구는 `소장중`으로 한다. +- 라이브 다시듣기 item tap은 오디오 상세 화면으로 이동한다. +- 본인 채널 하단 CTA 문구와 action은 사용자 요구사항의 `라이브 시작하기`를 기준으로 한다. +- `라이브 시작하기` 버튼 아이콘은 `ic_new_create_live`를 사용한다. + +## 12. Open Questions +- 해당 없음.