From 0b32a536c96adbbbccb4f4d423e3164278a14b93 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Sat, 4 Jul 2026 01:06:38 +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=ED=99=94=EB=A9=B4=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 | 32 +++ .../ic_new_player_play.imageset/Contents.json | 21 ++ .../ic_new_player_play.png | Bin 0 -> 249 bytes .../Contents.json | 21 ++ .../ic_new_shield_large.png | Bin 0 -> 321 bytes .../Contents.json | 21 ++ .../ic_new_shield_small.png | Bin 0 -> 328 bytes SodaLive/Sources/I18n/I18n.swift | 1 + .../CreatorChannelAudioContentListItem.swift | 45 ++-- .../CreatorChannelLiveReplayListItem.swift | 221 ++++++++++++++++++ .../CreatorChannelLiveStartButton.swift | 35 +++ .../Live/CreatorChannelLiveTabView.swift | 87 +++++++ .../plan-task.md | 7 +- 13 files changed, 473 insertions(+), 18 deletions(-) create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_new_player_play.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_new_player_play.imageset/ic_new_player_play.png create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_new_shield_large.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_new_shield_large.imageset/ic_new_shield_large.png create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_new_shield_small.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_new_shield_small.imageset/ic_new_shield_small.png create mode 100644 SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveReplayListItem.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveStartButton.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 4239c65d..3808253c 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -7,6 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + CC0201012FF0200000A5BBA3 /* CreatorChannelLiveReplayListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200012FF0200000A5BBA3 /* CreatorChannelLiveReplayListItem.swift */; }; + CC0202012FF0200000A5BBA3 /* CreatorChannelLiveReplayListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200012FF0200000A5BBA3 /* CreatorChannelLiveReplayListItem.swift */; }; + CC0203012FF0200000A5BBA3 /* CreatorChannelLiveReplayListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200012FF0200000A5BBA3 /* CreatorChannelLiveReplayListItem.swift */; }; + CC0201022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift */; }; + CC0202022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift */; }; + CC0203022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift */; }; + CC0201032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift */; }; + CC0202032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift */; }; + CC0203032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift */; }; 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 */; }; @@ -1801,6 +1810,9 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + CC0200012FF0200000A5BBA3 /* CreatorChannelLiveReplayListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLiveReplayListItem.swift; sourceTree = ""; }; + CC0200022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLiveStartButton.swift; sourceTree = ""; }; + CC0200032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLiveTabView.swift; sourceTree = ""; }; 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 = ""; }; @@ -2732,6 +2744,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + CC0200042FF0200000A5BBA3 /* Components */ = { + isa = PBXGroup; + children = ( + CC0200012FF0200000A5BBA3 /* CreatorChannelLiveReplayListItem.swift */, + CC0200022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift */, + ); + path = Components; + sourceTree = ""; + }; CC0100312FF0100000A5BBA3 /* Models */ = { isa = PBXGroup; children = ( @@ -2754,7 +2775,9 @@ children = ( CC0100312FF0100000A5BBA3 /* Models */, CC0100322FF0100000A5BBA3 /* Repository */, + CC0200042FF0200000A5BBA3 /* Components */, CC0100302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift */, + CC0200032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift */, ); path = Live; sourceTree = ""; @@ -5761,6 +5784,9 @@ CC01012E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */, CC01012F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */, CC0101302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift in Sources */, + CC0201012FF0200000A5BBA3 /* CreatorChannelLiveReplayListItem.swift in Sources */, + CC0201022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift in Sources */, + CC0201032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift in Sources */, 409E35082B0759DE00CCB985 /* MoyaProviderExtension.swift in Sources */, 40DDBD772B2B4FD700A2E8DA /* CreatorCommunityMoreItemView.swift in Sources */, 400EA7742A89C42C004992CF /* StartLiveRequest.swift in Sources */, @@ -6580,6 +6606,9 @@ CC01022E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */, CC01022F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */, CC0102302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift in Sources */, + CC0202012FF0200000A5BBA3 /* CreatorChannelLiveReplayListItem.swift in Sources */, + CC0202022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift in Sources */, + CC0202032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift in Sources */, 401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, 401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, @@ -7415,6 +7444,9 @@ CC01032E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */, CC01032F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */, CC0103302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift in Sources */, + CC0203012FF0200000A5BBA3 /* CreatorChannelLiveReplayListItem.swift in Sources */, + CC0203022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift in Sources */, + CC0203032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift in Sources */, 401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, 401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_new_player_play.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_new_player_play.imageset/Contents.json new file mode 100644 index 00000000..db63238a --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_new_player_play.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ic_new_player_play.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_new_player_play.imageset/ic_new_player_play.png b/SodaLive/Resources/Assets.xcassets/v2/ic_new_player_play.imageset/ic_new_player_play.png new file mode 100644 index 0000000000000000000000000000000000000000..ba1303c3ba8b75b69d5d2e497adba0ee57603a24 GIT binary patch literal 249 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fifkY zE{-7;j8`W*@-`Ra87Lykx5`=_h4Z6aA1-OXkZOsDEIvCTyfx=qt(eJCzsyL zsXlOj3jgMlYtD!&&8|FV?9jki($V{bVOwS}(~AH89-Iqo^~Haj%aaq3))AQhU^AQk nPWB#w>n}>PInJcD=j>nzlr#Tu?3(6np#2P``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fiinM zT^vIy7|#Yd@*Xze+0v#Uz@#X^w2&u?O}d%ykVFm-TcQBl!ejwn$Fh0rCT-sqcq?4@ zuKGS}>us)O(w6D`HjQ;_4Vc6g7|RnBqD*6jN>zG41}I-XdGd*{b@4R^R&#|vD$81# ztV1Rz2*0}F`PaOCV*P}-6W1K%=J*=*@j$eX_kG2y8V9pW*#7tTHFt`#S*?E^^XSaO zA5A;-i*}rgWV+Y4r`e55bk5Fw)7(We=d23fTQD>38r$mKLeH65KXClpVVv0m^aq2d LtDnm{r-UW|Gg5D@ literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_new_shield_small.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_new_shield_small.imageset/Contents.json new file mode 100644 index 00000000..88e33bc0 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_new_shield_small.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ic_new_shield_small.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_new_shield_small.imageset/ic_new_shield_small.png b/SodaLive/Resources/Assets.xcassets/v2/ic_new_shield_small.imageset/ic_new_shield_small.png new file mode 100644 index 0000000000000000000000000000000000000000..fe0a42b7144992828bcd0ba25c325d4e326dde37 GIT binary patch literal 328 zcmeAS@N?(olHy`uVBq!ia0vp^d?3uh1|;P@bT0xa&H|6fVg?3oVGw3ym^DWND9BhG zE zIep#t+q>rm1$%QGWcuviz*v2uQ95GTpR|&71rY}9+7Ax$O-=x@ z8O@op@Y$7|L)#LQtfyQxtT?+nap{42+gqm_56Wf+-(Jk#w6-d@_xQ9q!FK+gcMo^% Tym~Jm=p6=6S3j3^P6 Void - + init( audioContent: CreatorChannelAudioContentResponse, action: @escaping () -> Void = {} @@ -11,19 +11,19 @@ struct CreatorChannelAudioContentListItem: View { self.audioContent = audioContent self.action = action } - + var body: some View { Button(action: action) { HStack(alignment: .center, spacing: SodaSpacing.s14) { thumbnail - + VStack(alignment: .leading, spacing: 2) { Text(audioContent.title) .appFont(size: 16, weight: .bold) .foregroundColor(.white) .lineLimit(1) .truncationMode(.tail) - + if !subtitle.isEmpty { Text(subtitle) .appFont(size: 14, weight: .medium) @@ -38,7 +38,7 @@ struct CreatorChannelAudioContentListItem: View { } .buttonStyle(.plain) } - + private var subtitle: String { [audioContent.duration, audioContent.seriesName] .compactMap { value in @@ -47,7 +47,7 @@ struct CreatorChannelAudioContentListItem: View { } .joined(separator: " • ") } - + private var thumbnail: some View { ZStack(alignment: .topLeading) { DownsampledKFImage( @@ -55,39 +55,39 @@ struct CreatorChannelAudioContentListItem: View { size: CGSize(width: 88, height: 88) ) .background(Color.gray800) - + VStack(alignment: .leading, spacing: 0) { topTagList - + Spacer(minLength: 0) - + bottomTagList } } .frame(width: 88, height: 88) .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) } - + @ViewBuilder private var topTagList: some View { HStack(spacing: 0) { if audioContent.isOriginalSeries == true { CreatorChannelAudioImageTag(imageName: "ic_content_tag_original") } - + if audioContent.isFirstContent { CreatorChannelAudioFirstTag() } } } - + @ViewBuilder private var bottomTagList: some View { HStack(spacing: 0) { if audioContent.isPointAvailable { CreatorChannelAudioImageTag(imageName: "ic_content_tag_point") } - + if audioContent.price == 0 { CreatorChannelAudioFreeTag() } @@ -97,7 +97,7 @@ struct CreatorChannelAudioContentListItem: View { struct CreatorChannelAudioImageTag: View { let imageName: String - + var body: some View { Image(imageName) .resizable() @@ -110,7 +110,7 @@ struct CreatorChannelAudioFirstTag: View { var body: some View { ZStack { Color(hex: "FF34B8") - + Image("ic_content_tag_first_star") .resizable() .scaledToFit() @@ -120,6 +120,21 @@ struct CreatorChannelAudioFirstTag: View { } } +struct CreatorChannelAudioAdultTag: View { + var body: some View { + ZStack { + Color(hex: "FF4C3C") + + Image("ic_new_shield_small") + .resizable() + .scaledToFit() + .padding(2) + } + .frame(width: 18, height: 18) + .cornerRadius(4) + } +} + struct CreatorChannelAudioFreeTag: View { var body: some View { Text("무료") diff --git a/SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveReplayListItem.swift b/SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveReplayListItem.swift new file mode 100644 index 00000000..1bda0252 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveReplayListItem.swift @@ -0,0 +1,221 @@ +import SwiftUI + +struct CreatorChannelLiveReplayListItem: View { + let audioContent: CreatorChannelAudioContentResponse + let action: () -> Void + + init( + audioContent: CreatorChannelAudioContentResponse, + action: @escaping () -> Void = {} + ) { + self.audioContent = audioContent + self.action = action + } + + var body: some View { + Button { + action() + } label: { + HStack(alignment: .center, spacing: SodaSpacing.s14) { + thumbnail + + VStack(alignment: .leading, spacing: SodaSpacing.s6) { + Text(audioContent.title) + .appFont(size: 16, weight: .bold) + .foregroundColor(.white) + .lineLimit(2) + .truncationMode(.tail) + + if let duration = audioContent.duration, !duration.isEmpty { + Text(duration) + .appFont(size: 14, weight: .medium) + .foregroundColor(Color.gray500) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + trailingState + } + .padding(.horizontal, SodaSpacing.s20) + .padding(.vertical, SodaSpacing.s8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + + private var thumbnail: some View { + ZStack(alignment: .topLeading) { + DownsampledKFImage( + url: audioContent.imageUrl.flatMap(URL.init(string:)), + size: CGSize(width: 88, height: 88) + ) + .background(Color.gray800) + + VStack(alignment: .leading, spacing: 0) { + topTagList + + Spacer(minLength: 0) + + bottomTagList + } + } + .frame(width: 88, height: 88) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + + @ViewBuilder + private var topTagList: some View { + HStack(spacing: 0) { + if audioContent.isOriginalSeries == true { + CreatorChannelAudioImageTag(imageName: "ic_content_tag_original") + } + + if audioContent.isFirstContent { + CreatorChannelAudioFirstTag() + } + + Spacer() + + if audioContent.isAdult { + CreatorChannelAudioAdultTag() + .padding(.top, 6) + .padding(.trailing, 6) + } + } + } + + @ViewBuilder + private var bottomTagList: some View { + HStack(spacing: 0) { + if audioContent.isPointAvailable { + CreatorChannelAudioImageTag(imageName: "ic_content_tag_point") + } + + if audioContent.price == 0 { + CreatorChannelAudioFreeTag() + } + } + } + + @ViewBuilder + private var trailingState: some View { + if audioContent.isOwned { + playbackState(I18n.Content.Status.owned) + } else if audioContent.isRented { + playbackState(I18n.Content.Status.rented) + } else if audioContent.price == 0 { + playbackState("") + } else { + HStack(spacing: 2) { + Image("ic_bar_cash") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + + Text(audioContent.price.comma()) + .appFont(size: 14, weight: .regular) + .foregroundColor(.white) + .lineLimit(1) + } + } + } + + private func playbackState(_ title: String) -> some View { + VStack(spacing: SodaSpacing.s4) { + ZStack { + Color.white + + Image("ic_new_player_play") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + .frame(width: 28, height: 28) + .clipShape(Circle()) + + if !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(title) + .appFont(size: 12, weight: .medium) + .foregroundColor(Color.gray400) + .lineLimit(1) + } + } + } +} + +struct CreatorChannelLiveReplayListItem_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: SodaSpacing.s8) { + CreatorChannelLiveReplayListItem( + audioContent: CreatorChannelAudioContentResponse( + audioContentId: 1, + title: "라이브 다시듣기 제목이 두 줄까지 표시됩니다", + duration: "1:43:25", + imageUrl: "https://picsum.photos/300/300", + price: 10, + isAdult: true, + isPointAvailable: true, + isFirstContent: true, + seriesName: nil, + isOriginalSeries: true, + isOwned: true, + isRented: false + ) + ) + + CreatorChannelLiveReplayListItem( + audioContent: CreatorChannelAudioContentResponse( + audioContentId: 1, + title: "라이브 다시듣기 제목이 두 줄까지 표시됩니다", + duration: "1:43:25", + imageUrl: "https://picsum.photos/300/300", + price: 10, + isAdult: true, + isPointAvailable: true, + isFirstContent: true, + seriesName: nil, + isOriginalSeries: true, + isOwned: false, + isRented: true + ) + ) + + CreatorChannelLiveReplayListItem( + audioContent: CreatorChannelAudioContentResponse( + audioContentId: 1, + title: "라이브 다시듣기 제목이 두 줄까지 표시됩니다", + duration: "1:43:25", + imageUrl: "https://picsum.photos/300/300", + price: 3000, + isAdult: true, + isPointAvailable: true, + isFirstContent: true, + seriesName: nil, + isOriginalSeries: true, + isOwned: false, + isRented: false + ) + ) + + CreatorChannelLiveReplayListItem( + audioContent: CreatorChannelAudioContentResponse( + audioContentId: 1, + title: "라이브 다시듣기 제목이 두 줄까지 표시됩니다", + duration: "1:43:25", + imageUrl: "https://picsum.photos/300/300", + price: 0, + isAdult: true, + isPointAvailable: true, + isFirstContent: true, + seriesName: nil, + isOriginalSeries: true, + isOwned: false, + isRented: false + ) + ) + } + .background(Color.black) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveStartButton.swift b/SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveStartButton.swift new file mode 100644 index 00000000..4b36058c --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveStartButton.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct CreatorChannelLiveStartButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: SodaSpacing.s6) { + Image("ic_new_create_live") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + + Text(I18n.CreatorChannelLive.startLive) + .appFont(size: 16, weight: .bold) + .foregroundColor(.white) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .background(Color.soda400) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} + +struct CreatorChannelLiveStartButton_Previews: PreviewProvider { + static var previews: some View { + CreatorChannelLiveStartButton(action: {}) + .padding(SodaSpacing.s20) + .background(Color.black) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift b/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift new file mode 100644 index 00000000..311c0725 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct CreatorChannelLiveTabView: View { + let creatorId: Int + let isOwnCreatorChannel: Bool + let onTapLive: (Int) -> Void + let onTapContent: (Int) -> Void + let onTapCreateLive: () -> Void + + @StateObject private var viewModel = CreatorChannelLiveViewModel() + @State private var isSortSheetPresented = false + + var body: some View { + ZStack(alignment: .bottom) { + content + + if isOwnCreatorChannel { + CreatorChannelLiveStartButton(action: onTapCreateLive) + .padding(.horizontal, SodaSpacing.s20) + .padding(.bottom, SodaSpacing.s20) + } + } + .background(Color.black) + .onAppear { + if viewModel.hasLoaded == false { + viewModel.fetchFirstPage(creatorId: creatorId) + } + } + .sheet(isPresented: $isSortSheetPresented) { + CreatorChannelSortBottomSheet(selectedSort: viewModel.selectedSort) { sort in + viewModel.selectSort(sort, creatorId: creatorId) + isSortSheetPresented = false + } + } + .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1) + } + + private var content: some View { + VStack(spacing: 0) { + CreatorChannelSortBar( + totalCount: viewModel.response?.liveReplayContentCount ?? 0, + selectedSort: viewModel.selectedSort, + onTapSort: { + isSortSheetPresented = true + } + ) + + LazyVStack(spacing: 0) { + CreatorChannelCurrentLiveSection( + currentLive: viewModel.response?.currentLive, + onTapLive: onTapLive + ) + .padding(.top, SodaSpacing.s14) + .padding(.bottom, SodaSpacing.s12) + + ForEach(viewModel.liveReplayContents) { audioContent in + CreatorChannelLiveReplayListItem(audioContent: audioContent) { + onTapContent(audioContent.audioContentId) + } + .onAppear { + viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: audioContent) + } + } + + if viewModel.isLoadingNextPage { + ProgressView() + .padding(.vertical, SodaSpacing.s20) + } + } + .padding(.bottom, isOwnCreatorChannel ? 96 : SodaSpacing.s20) + } + } +} + +struct CreatorChannelLiveTabView_Previews: PreviewProvider { + static var previews: some View { + CreatorChannelLiveTabView( + creatorId: 1, + isOwnCreatorChannel: true, + onTapLive: { _ in }, + onTapContent: { _ in }, + onTapCreateLive: {} + ) + .background(Color.black) + .previewLayout(.sizeThatFits) + } +} diff --git a/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md b/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md index b5ed3855..54669e2d 100644 --- a/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md +++ b/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md @@ -179,7 +179,7 @@ ### Phase 4: 라이브 탭 UI -- [ ] **Task 4.1: 라이브 다시듣기 item 생성** +- [x] **Task 4.1: 라이브 다시듣기 item 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveReplayListItem.swift` - 확인: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelAudioContentListItem.swift` @@ -199,7 +199,7 @@ - 실행 명령: `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 생성** +- [x] **Task 4.2: 라이브 시작하기 하단 CTA 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/CreatorChannel/Live/Components/CreatorChannelLiveStartButton.swift` - 수정: `SodaLive/Sources/I18n/I18n.swift` @@ -215,7 +215,7 @@ - 실행 명령: `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: 라이브 탭 화면 생성** +- [x] **Task 4.3: 라이브 탭 화면 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift` - 확인: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelCurrentLiveSection.swift` @@ -306,3 +306,4 @@ - 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/계획/구현을 정렬함. +- 2026-07-04: Phase 4 구현 완료. `CreatorChannelLiveReplayListItem`, `CreatorChannelLiveStartButton`, `CreatorChannelLiveTabView`를 추가하고 `I18n.CreatorChannelLive.startLive` 문구를 추가함. 저장소에 별도 XCTest 파일이 없어 Phase 4는 `rg` 정적 검색, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`로 검증했으며 `** BUILD SUCCEEDED **`를 확인함.