diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index edb012b0..2c59b474 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -1708,7 +1708,7 @@ CC0101292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */; }; CC01012A2FF0100000A5BBA3 /* ContentSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002A2FF0100000A5BBA3 /* ContentSort.swift */; }; CC01012B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */; }; - CC01012C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift */; }; + CC01012C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */; }; CC01012D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift */; }; CC01012E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift */; }; CC01012F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift */; }; @@ -1741,7 +1741,7 @@ CC0102292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */; }; CC01022A2FF0100000A5BBA3 /* ContentSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002A2FF0100000A5BBA3 /* ContentSort.swift */; }; CC01022B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */; }; - CC01022C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift */; }; + CC01022C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */; }; CC01022D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift */; }; CC01022E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift */; }; CC01022F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift */; }; @@ -1774,7 +1774,7 @@ CC0103292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */; }; CC01032A2FF0100000A5BBA3 /* ContentSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002A2FF0100000A5BBA3 /* ContentSort.swift */; }; CC01032B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */; }; - CC01032C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift */; }; + CC01032C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */; }; CC01032D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift */; }; CC01032E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift */; }; CC01032F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift */; }; @@ -2670,7 +2670,7 @@ CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelFloatingActionMenu.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 = ""; }; + CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSortContextPopup.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 = ""; }; @@ -5313,7 +5313,7 @@ CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */, CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */, CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */, - CC01002C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift */, + CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */, CC0200012FF0200000A5BBA3 /* CreatorChannelAudioContentListItem.swift */, ); path = Components; @@ -5846,7 +5846,7 @@ CC0101102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, CC01012A2FF0100000A5BBA3 /* ContentSort.swift in Sources */, CC01012B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */, - CC01012C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift in Sources */, + CC01012C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift in Sources */, CC01012D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift in Sources */, CC01012E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */, CC01012F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */, @@ -6675,7 +6675,7 @@ CC0102102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, CC01022A2FF0100000A5BBA3 /* ContentSort.swift in Sources */, CC01022B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */, - CC01022C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift in Sources */, + CC01022C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift in Sources */, CC01022D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift in Sources */, CC01022E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */, CC01022F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */, @@ -7520,7 +7520,7 @@ CC0103102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, CC01032A2FF0100000A5BBA3 /* ContentSort.swift in Sources */, CC01032B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */, - CC01032C2FF0100000A5BBA3 /* CreatorChannelSortBottomSheet.swift in Sources */, + CC01032C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift in Sources */, CC01032D2FF0100000A5BBA3 /* CreatorChannelLiveTabResponse.swift in Sources */, CC01032E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */, CC01032F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */, diff --git a/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift b/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift index 021627ec..aef55ccb 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift @@ -6,7 +6,7 @@ struct CreatorChannelAudioTabView: View { let onTapContent: (Int) -> Void @StateObject private var viewModel = CreatorChannelAudioViewModel() - @State private var isSortSheetPresented = false + @State private var isSortPopupPresented = false var body: some View { content @@ -16,12 +16,6 @@ struct CreatorChannelAudioTabView: View { 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) } @@ -30,47 +24,79 @@ struct CreatorChannelAudioTabView: View { if isEmptyState { emptyStateView } else { - CapsuleTabBar( - items: viewModel.displayThemes, - selectedItem: Binding( - get: { viewModel.selectedTheme }, - set: { viewModel.selectTheme($0, creatorId: creatorId) } - ), - title: { $0.themeName } - ) - - CreatorChannelSortBar( - selectedSort: viewModel.selectedSort, - onTapSort: { - isSortSheetPresented = true - } - ) - - LazyVStack(spacing: 0) { - if shouldShowOwnershipRate, let response = viewModel.response { - CreatorChannelAudioOwnershipRateView( - purchasedAudioContentRate: response.purchasedAudioContentRate, - purchasedAudioContentCount: response.purchasedAudioContentCount, - paidAudioContentCount: response.paidAudioContentCount + ZStack(alignment: .topTrailing) { + VStack(spacing: 0) { + CapsuleTabBar( + items: viewModel.displayThemes, + selectedItem: Binding( + get: { viewModel.selectedTheme }, + set: { viewModel.selectTheme($0, creatorId: creatorId) } + ), + title: { $0.themeName } ) - .padding(.bottom, SodaSpacing.s8) + + CreatorChannelSortBar( + selectedSort: viewModel.selectedSort, + totalCount: viewModel.response?.audioContentCount, + onTapSort: { + isSortPopupPresented.toggle() + } + ) + + LazyVStack(spacing: 0) { + if shouldShowOwnershipRate, let response = viewModel.response { + CreatorChannelAudioOwnershipRateView( + purchasedAudioContentRate: response.purchasedAudioContentRate, + purchasedAudioContentCount: response.purchasedAudioContentCount, + paidAudioContentCount: response.paidAudioContentCount + ) + .padding(.bottom, SodaSpacing.s8) + } + + ForEach(viewModel.audioContents) { audioContent in + CreatorChannelAudioContentListItem(audioContent: audioContent) { + onTapContent(audioContent.audioContentId) + } + .onAppear { + viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: audioContent) + } + } + + if viewModel.isLoadingNextPage { + ProgressView() + .padding(.vertical, SodaSpacing.s20) + } + } + .padding(.bottom, isOwnCreatorChannel ? 70 : SodaSpacing.s20) } - ForEach(viewModel.audioContents) { audioContent in - CreatorChannelAudioContentListItem(audioContent: audioContent) { - onTapContent(audioContent.audioContentId) - } - .onAppear { - viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: audioContent) - } - } + if isSortPopupPresented { + Color.black + .opacity(0.001) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture { + isSortPopupPresented = false + } - if viewModel.isLoadingNextPage { - ProgressView() - .padding(.vertical, SodaSpacing.s20) + CreatorChannelSortContextPopup(selectedSort: viewModel.selectedSort) { sort in + viewModel.selectSort(sort, creatorId: creatorId) + isSortPopupPresented = false + } + .padding(.top, 104) + .padding(.trailing, SodaSpacing.s14) + .zIndex(1) } } - .padding(.bottom, isOwnCreatorChannel ? 70 : SodaSpacing.s20) + .frame(maxWidth: .infinity, alignment: .topTrailing) + .onChange(of: viewModel.selectedSort) { _ in + isSortPopupPresented = false + } + .onChange(of: viewModel.selectedTheme) { _ in + isSortPopupPresented = false + } + .onDisappear { + isSortPopupPresented = false + } } } } diff --git a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift index b35a557e..c7bd3ce4 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift @@ -2,26 +2,49 @@ import SwiftUI struct CreatorChannelSortBar: View { let selectedSort: ContentSort + let totalCount: Int? let onTapSort: () -> Void + init( + selectedSort: ContentSort, + totalCount: Int? = nil, + onTapSort: @escaping () -> Void + ) { + self.selectedSort = selectedSort + self.totalCount = totalCount + self.onTapSort = onTapSort + } + var body: some View { HStack(alignment: .center, spacing: SodaSpacing.s8) { + if let totalCount, totalCount >= 0 { + HStack(spacing: SodaSpacing.s4) { + Text(I18n.CreatorChannelLive.totalLabel) + .appFont(size: 16, weight: .medium) + .foregroundColor(Color.white) + + Text("\(totalCount)") + .appFont(size: 16, weight: .medium) + .foregroundColor(Color.gray500) + } + } + Spacer() Button(action: onTapSort) { HStack(spacing: SodaSpacing.s4) { Text(selectedSort.title) - .appFont(size: 14, weight: .medium) - .foregroundColor(Color.white) + .appFont(size: 16, weight: .regular) + .foregroundColor(Color.gray400) Image("ic_new_sort") - .font(.system(size: 12, weight: .semibold)) + .frame(width: 18, height: 18) } .contentShape(Rectangle()) } .buttonStyle(.plain) } - .padding(.horizontal, SodaSpacing.s20) + .padding(.horizontal, SodaSpacing.s14) .frame(height: 52) .background(Color.black) } @@ -29,7 +52,7 @@ struct CreatorChannelSortBar: View { struct CreatorChannelSortBar_Previews: PreviewProvider { static var previews: some View { - CreatorChannelSortBar(selectedSort: .latest, onTapSort: {}) + CreatorChannelSortBar(selectedSort: .latest, totalCount: 23, onTapSort: {}) .previewLayout(.sizeThatFits) } } diff --git a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBottomSheet.swift b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBottomSheet.swift deleted file mode 100644 index ad80b1c1..00000000 --- a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBottomSheet.swift +++ /dev/null @@ -1,45 +0,0 @@ -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/Components/CreatorChannelSortContextPopup.swift b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortContextPopup.swift new file mode 100644 index 00000000..1cc57c6a --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortContextPopup.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct CreatorChannelSortContextPopup: 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(alignment: .leading, spacing: 0) { + ForEach(sorts, id: \.self) { sort in + Button { + onSelect(sort) + } label: { + Text(sort.title) + .appFont(size: 16, weight: .medium) + .foregroundColor(Color.white) + .lineLimit(1) + .frame(minWidth: 92, alignment: .leading) + .padding(.horizontal, SodaSpacing.s12) + .padding(.vertical, SodaSpacing.s8) + .background(selectedSort == sort ? Color.gray800 : Color.gray900) + } + .buttonStyle(.plain) + } + } + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous) + .stroke(Color.gray700, lineWidth: 1) + ) + } +} + +struct CreatorChannelSortContextPopup_Previews: PreviewProvider { + static var previews: some View { + CreatorChannelSortContextPopup(selectedSort: .latest, onSelect: { _ in }) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift b/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift index 9e618da0..32bfbbf6 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift @@ -7,7 +7,7 @@ struct CreatorChannelLiveTabView: View { let onTapContent: (Int) -> Void @StateObject private var viewModel = CreatorChannelLiveViewModel() - @State private var isSortSheetPresented = false + @State private var isSortPopupPresented = false var body: some View { content @@ -17,12 +17,6 @@ struct CreatorChannelLiveTabView: View { 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) } @@ -31,36 +25,65 @@ struct CreatorChannelLiveTabView: View { if isEmptyState { emptyStateView } else { - CreatorChannelSortBar( - selectedSort: viewModel.selectedSort, - onTapSort: { - isSortSheetPresented = true - } - ) + ZStack(alignment: .topTrailing) { + VStack(spacing: 0) { + CreatorChannelSortBar( + selectedSort: viewModel.selectedSort, + totalCount: viewModel.response?.liveReplayContentCount, + onTapSort: { + isSortPopupPresented.toggle() + } + ) - LazyVStack(spacing: 0) { - CreatorChannelCurrentLiveSection( - currentLive: viewModel.response?.currentLive, - onTapLive: onTapLive - ) - .padding(.top, SodaSpacing.s14) - .padding(.bottom, SodaSpacing.s12) + LazyVStack(spacing: 0) { + CreatorChannelCurrentLiveSection( + currentLive: viewModel.response?.currentLive, + onTapLive: onTapLive + ) + .padding(.top, SodaSpacing.s14) + .padding(.bottom, SodaSpacing.s12) - ForEach(viewModel.liveReplayContents) { audioContent in - CreatorChannelAudioContentListItem(audioContent: audioContent) { - onTapContent(audioContent.audioContentId) - } - .onAppear { - viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: audioContent) + ForEach(viewModel.liveReplayContents) { audioContent in + CreatorChannelAudioContentListItem(audioContent: audioContent) { + onTapContent(audioContent.audioContentId) + } + .onAppear { + viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: audioContent) + } + } + + if viewModel.isLoadingNextPage { + ProgressView() + .padding(.vertical, SodaSpacing.s20) + } } + .padding(.bottom, isOwnCreatorChannel ? 70 : SodaSpacing.s20) } - if viewModel.isLoadingNextPage { - ProgressView() - .padding(.vertical, SodaSpacing.s20) + if isSortPopupPresented { + Color.black + .opacity(0.001) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture { + isSortPopupPresented = false + } + + CreatorChannelSortContextPopup(selectedSort: viewModel.selectedSort) { sort in + viewModel.selectSort(sort, creatorId: creatorId) + isSortPopupPresented = false + } + .padding(.top, 52) + .padding(.trailing, SodaSpacing.s14) + .zIndex(1) } } - .padding(.bottom, isOwnCreatorChannel ? 70 : SodaSpacing.s20) + .frame(maxWidth: .infinity, alignment: .topTrailing) + .onChange(of: viewModel.selectedSort) { _ in + isSortPopupPresented = false + } + .onDisappear { + isSortPopupPresented = false + } } } } diff --git a/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md b/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md index 28d5636d..acf7cc67 100644 --- a/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md +++ b/docs/20260703_크리에이터_채널_라이브_탭/plan-task.md @@ -22,7 +22,7 @@ - 홈 탭의 `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/**` 아래에 둔다. +- sort-bar와 정렬 context popup은 `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` 추가 요청을 수행하고, 추가 요청 중 중복 호출을 막는다. @@ -34,7 +34,7 @@ ### 생성 - `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/Components/CreatorChannelSortContextPopup.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` @@ -145,7 +145,7 @@ - 실행 명령: `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 +### Phase 3: sort-bar와 정렬 context popup - [x] **Task 3.1: sort-bar 공통 컴포넌트 생성** - 대상 파일: @@ -161,19 +161,19 @@ - 실행 명령: `rg "struct CreatorChannelSortBar|selectedSort: ContentSort|onTapSort|selectedSort.title|frame\\(height: 52" SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift` - 기대 결과: 현재 정렬과 터치 action이 있는 sort-bar가 확인된다. -- [x] **Task 3.2: 정렬 BottomSheet 컴포넌트 생성** +- [x] **Task 3.2: 정렬 context popup 컴포넌트 생성** - 대상 파일: - - 생성: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBottomSheet.swift` + - 생성: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortContextPopup.swift` - 작업 내용: - - `CreatorChannelSortBottomSheet`를 `View`로 생성한다. + - `CreatorChannelSortContextPopup`를 `View`로 생성한다. - 입력값은 `selectedSort: ContentSort`, `sorts: [ContentSort] = ContentSort.allCases`, `onSelect: (ContentSort) -> Void`로 둔다. - 항목은 `LATEST`, `POPULAR`, `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 4개 항목 기반 BottomSheet가 확인된다. - - 실행 명령: `rg "추천순|recommend" SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBottomSheet.swift` + - 실행 명령: `rg "struct CreatorChannelSortContextPopup|selectedSort: ContentSort|ContentSort.allCases|onSelect|ForEach|sort.title" SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortContextPopup.swift` + - 기대 결과: ContentSort 4개 항목 기반 context popup이 확인된다. + - 실행 명령: `rg "추천순|recommend" SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortContextPopup.swift` - 기대 결과: 검색 결과가 없다. ### Phase 4: 라이브 탭 UI @@ -223,13 +223,13 @@ - 입력값은 `creatorId: Int`, `isOwnCreatorChannel: Bool`, `onTapLive: (Int) -> Void`, `onTapContent: (Int) -> Void`로 둔다. - 내부에서 `@StateObject private var viewModel = CreatorChannelLiveViewModel()`을 사용한다. - `onAppear`에서 `hasLoaded == false`이면 `fetchFirstPage(creatorId:)`를 호출한다. - - sort-bar 터치 시 `.sheet` 또는 기존 앱 sheet 패턴으로 `CreatorChannelSortBottomSheet`를 표시한다. + - sort-bar 터치 시 `CreatorChannelSortContextPopup`를 표시한다. - `currentLive != nil`이면 `CreatorChannelCurrentLiveSection(currentLive:onTapLive:)`를 표시한다. - `liveReplayContents`는 `LazyVStack`으로 표시하고 각 item의 `onAppear`에서 `fetchNextPageIfNeeded`를 호출한다. - item tap은 `onTapContent(audioContent.audioContentId)`로 연결한다. - CTA가 부모 화면의 고정 overlay와 겹치지 않도록 scroll content bottom padding을 추가한다. - 검증 기준: - - 실행 명령: `rg "struct CreatorChannelLiveTabView|CreatorChannelLiveViewModel|fetchFirstPage\\(creatorId: creatorId\\)|CreatorChannelSortBar|CreatorChannelSortBottomSheet|CreatorChannelCurrentLiveSection|CreatorChannelAudioContentListItem|fetchNextPageIfNeeded|onTapContent\\(audioContent.audioContentId\\)|isOwnCreatorChannel" SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift` + - 실행 명령: `rg "struct CreatorChannelLiveTabView|CreatorChannelLiveViewModel|fetchFirstPage\\(creatorId: creatorId\\)|CreatorChannelSortBar|CreatorChannelSortContextPopup|CreatorChannelCurrentLiveSection|CreatorChannelAudioContentListItem|fetchNextPageIfNeeded|onTapContent\\(audioContent.audioContentId\\)|isOwnCreatorChannel" SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift` - 기대 결과: 라이브 탭 화면, 정렬, 현재 라이브, 다시듣기 목록, pagination이 확인된다. - [x] **Task 4.4: 라이브 탭 Empty 상태 추가** @@ -321,9 +321,9 @@ - 확인: `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`, 오디오 상세 이동 연결을 검색한다. + - API endpoint, query parameter, 정렬 enum, `page=0`, `size=20`, sort-bar, context popup, current live 재사용, 다시듣기 item, `ic_new_create_live`, 오디오 상세 이동 연결을 검색한다. - 검증 기준: - - 실행 명령: `rg "CreatorChannelLiveApi|/api/v2/creator-channels/|/live|URLEncoding.queryString|ContentSort|pageSize|20|page = 0|CreatorChannelSortBar|CreatorChannelSortBottomSheet|CreatorChannelCurrentLiveSection|CreatorChannelAudioContentListItem|ic_new_create_live|contentDetail\\(contentId:" SodaLive/Sources/V2/CreatorChannel SodaLive/Sources/I18n/I18n.swift SodaLive/Sources/App/AppStep.swift` + - 실행 명령: `rg "CreatorChannelLiveApi|/api/v2/creator-channels/|/live|URLEncoding.queryString|ContentSort|pageSize|20|page = 0|CreatorChannelSortBar|CreatorChannelSortContextPopup|CreatorChannelCurrentLiveSection|CreatorChannelAudioContentListItem|ic_new_create_live|contentDetail\\(contentId:" SodaLive/Sources/V2/CreatorChannel SodaLive/Sources/I18n/I18n.swift SodaLive/Sources/App/AppStep.swift` - 기대 결과: PRD 핵심 구현 지점이 모두 검색된다. - [x] **Task 7.2: 빌드 검증** @@ -342,7 +342,7 @@ - 작업 내용: - 라이브 탭 진입 시 첫 요청이 `page=0`, `size=20`, `sort=LATEST`로 호출되는지 확인한다. - sort-bar에 `최신순`이 표시되는지 확인한다. - - sort-bar 터치 시 BottomSheet가 표시되고 4개 정렬만 보이는지 확인한다. + - sort-bar 터치 시 context popup이 표시되고 4개 정렬만 보이는지 확인한다. - 정렬 변경 시 첫 페이지부터 다시 조회되는지 확인한다. - `currentLive != nil`인 경우 홈 탭 현재 라이브 UI와 동일한 카드가 표시되는지 확인한다. - 마지막 item 노출 시 `hasNext == true`이면 `page + 1`이 호출되고 목록이 append되는지 확인한다. @@ -355,6 +355,47 @@ - 기대 결과: PRD Success Criteria를 모두 만족한다. - blocked 사유: 실제 로그인 세션, 타인/본인 크리에이터 채널 테스트 데이터, 라이브/다시듣기 응답 데이터, 네트워크 요청 관찰 수단이 필요해 현재 CLI 환경에서는 완료 검증하지 않는다. +### Phase 8: sort-bar 전체 개수와 정렬 context popup 변경 + +- [x] **Task 8.1: sort-bar 전체 개수 표시 추가** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift` + - 작업 내용: + - `CreatorChannelSortBar`에 `totalCount: Int?` 입력을 추가한다. + - `totalCount >= 0`이면 좌측에 `전체 {totalCount}`를 표시한다. + - 라이브 탭은 `liveReplayContentCount`, 오디오 탭은 `audioContentCount`를 전달한다. + - 검증 기준: + - 실행 명령: `rg "totalCount|전체|liveReplayContentCount|audioContentCount" SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift` + - 기대 결과: sort-bar 입력과 두 탭의 count 전달이 확인된다. + +- [x] **Task 8.2: 정렬 UI를 context popup으로 변경** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortContextPopup.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift` + - 작업 내용: + - 기존 정렬 컴포넌트 내용을 anchored context popup 형태로 교체한다. + - popup은 `ContentSort.allCases` 4개만 표시한다. + - 선택 항목은 `Color.gray800`, 기본 항목은 `Color.gray900` 배경을 사용한다. + - sort-bar 터치 시 `.sheet`가 아니라 현재 화면 위 overlay로 popup을 표시한다. + - popup 밖 터치와 항목 선택 시 popup을 닫는다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelSortContextPopup|isSortPopupPresented|overlay|Color.gray700|cornerRadius: SodaSpacing.s14|\\.sheet\\(isPresented: \\$isSort" SodaLive/Sources/V2/CreatorChannel` + - 기대 결과: context popup 구현과 사용처가 확인되고 정렬 sheet 사용처는 검색되지 않는다. + +- [x] **Task 8.3: 변경 검증** + - 대상 파일: + - 확인: `SodaLive/Sources/V2/CreatorChannel` + - 확인: `docs/20260703_크리에이터_채널_라이브_탭` + - 확인: `docs/20260704_크리에이터_채널_오디오_탭` + - 검증 기준: + - 실행 명령: `git diff --check -- docs/20260703_크리에이터_채널_라이브_탭 docs/20260704_크리에이터_채널_오디오_탭 SodaLive/Sources/V2/CreatorChannel` + - 기대 결과: 공백 오류 없이 성공한다. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 기대 결과: 빌드 성공. + ## 검증 기록 - 2026-07-03: 문서 작성 단계. 구현/빌드 검증은 아직 수행하지 않음. @@ -365,9 +406,13 @@ - 2026-07-04: Phase 6 리팩토링 완료. `CreatorChannelLiveReplayListItem`을 공용 `CreatorChannelAudioContentListItem`으로 이동/이름 변경하고, 홈 전용 `CreatorChannelAudioContentListItem`을 `CreatorChannelHomeAudioContentListItem`으로 변경함. `rg` 정적 검색, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`로 검증했으며 `** BUILD SUCCEEDED **`를 확인함. - 2026-07-04: Phase 5 구현 완료. `CreatorChannelView`의 `.live` 탭 분기에 `CreatorChannelLiveTabView`를 연결하고 `creatorId`, `isOwnCreatorChannel`, `showLiveDetail`, `showContentDetail`, `showCreateLive`를 전달함. 기존 `CreatorChannelFloatingActionMenu`는 본인 채널 `home` 탭 조건으로 유지함. `rg` 정적 검색, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`로 검증했으며 `** BUILD SUCCEEDED **`를 확인함. - 2026-07-04: `ContentSort`에서 소장 정렬 옵션을 제거하고 PRD/계획서의 정렬 옵션을 4개 기준으로 갱신함. `rg` 정적 검색, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`로 검증했으며 `** BUILD SUCCEEDED **`를 확인함. -- 2026-07-04: Phase 7.1 정적 검색 검증 완료. API endpoint, query parameter, 정렬 enum 4개, pagination 기본값, sort-bar, BottomSheet, 현재 라이브 재사용, 공용 다시듣기 item, `ic_new_create_live`, 오디오 상세 이동 연결을 확인함. `plutil -lint SodaLive.xcodeproj/project.pbxproj`도 `OK`를 확인함. +- 2026-07-04: Phase 7.1 정적 검색 검증 완료. API endpoint, query parameter, 정렬 enum 4개, pagination 기본값, sort-bar, context popup, 현재 라이브 재사용, 공용 다시듣기 item, `ic_new_create_live`, 오디오 상세 이동 연결을 확인함. `plutil -lint SodaLive.xcodeproj/project.pbxproj`도 `OK`를 확인함. - 2026-07-04: Phase 7.2 빌드 검증 완료. 최초 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`는 DerivedData `build.db` lock으로 실패했으나, 5초 후 동일 명령 재시도에서 `** BUILD SUCCEEDED **`를 확인함. - 2026-07-04: Phase 7.3 수동 동작 검증은 blocked. iOS Simulator는 사용 가능하나 실제 로그인 세션, 타인/본인 크리에이터 채널 테스트 데이터, 라이브/다시듣기 응답 데이터, 네트워크 요청 관찰 수단이 없어 CLI 환경에서 PRD Success Criteria 전체를 완료 검증하지 않음. - 2026-07-04: 리뷰 반영. sort-bar 카운트 표시 요구사항이 제거되어 PRD/계획서를 현재 정렬 표시 기준으로 갱신함. - 2026-07-04: 리뷰 반영. 본인 채널 라이브 시작하기 CTA를 `CreatorChannelLiveTabView` 내부 scroll content overlay에서 `CreatorChannelView` 최상위 overlay로 이동해 화면 하단 고정 구조로 변경함. - 2026-07-04: 요구사항 추가 반영. 진행 중인 라이브가 없고 라이브 다시듣기 콘텐츠가 0개인 경우 sort-bar/list를 숨기고 Empty 문구만 표시하도록 PRD/계획/구현을 갱신함. +- 2026-07-04: Figma `290:9034`, `290:9031`을 확인하고 Phase 8을 추가함. sort-bar 전체 개수 표시와 context popup 전환을 후속 변경 범위로 정의함. +- 2026-07-04: Phase 8 구현 완료. `CreatorChannelSortBar`에 `totalCount`를 추가하고 라이브/오디오 탭에서 각각 `liveReplayContentCount`, `audioContentCount`를 전달함. 기존 정렬 타입을 `CreatorChannelSortContextPopup`으로 교체하고 `.sheet` 사용을 overlay popup으로 변경함. +- 2026-07-04: Phase 8 검증 완료. `rg "totalCount|전체|liveReplayContentCount|audioContentCount" ...`, `rg "CreatorChannelSortContextPopup|isSortPopupPresented|Color.gray700|cornerRadius: SodaSpacing.s14|\\.sheet\\(isPresented: \\$isSort" ...`, `git diff --check -- docs/20260703_크리에이터_채널_라이브_탭 docs/20260704_크리에이터_채널_오디오_탭 SodaLive/Sources/V2/CreatorChannel`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`를 확인함. 샌드박스 안 빌드는 Xcode 캐시 권한 문제로 실패했으나, 샌드박스 밖 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`에서 `** BUILD SUCCEEDED **`를 확인함. +- 2026-07-04: 후속 반영. 정렬 popup 파일명을 `CreatorChannelSortContextPopup.swift`로 변경하고 Xcode project 참조와 문서 참조를 갱신함. 이전 정렬 파일명/타입 참조가 남지 않았음을 확인하고, 샌드박스 밖 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`에서 `** BUILD SUCCEEDED **`를 확인함. diff --git a/docs/20260703_크리에이터_채널_라이브_탭/prd.md b/docs/20260703_크리에이터_채널_라이브_탭/prd.md index db1f4cc5..efaf804a 100644 --- a/docs/20260703_크리에이터_채널_라이브_탭/prd.md +++ b/docs/20260703_크리에이터_채널_라이브_탭/prd.md @@ -19,7 +19,7 @@ Figma 참조: - 현재 크리에이터 채널 공통 shell은 홈 탭 중심으로 구현되어 있고, `라이브` 탭의 실제 목록 API, 정렬, 페이지네이션이 연결되어 있지 않다. - 라이브 탭은 홈 탭의 현재 진행 중인 라이브 UI를 재사용해야 하지만, 라이브 다시듣기 목록은 가격, 포인트 사용 가능, 19금, 소장중, 대여중 상태를 함께 표시해야 하므로 별도 item 요구사항이 필요하다. - sort-bar는 라이브 탭 전용이 아니라 다른 탭에서도 같은 형태로 재사용될 수 있어 채널 공통 컴포넌트 경계가 필요하다. -- 정렬 선택 UI는 Figma에 anchored context menu 형태가 있으나, 모바일 하단 접근성과 옵션 확장성을 고려해 앱 구현 방식 결정이 필요하다. +- 정렬 선택 UI는 기존 BottomSheet에서 Figma와 같은 anchored context popup 형태로 변경해야 한다. - 본인 채널의 라이브 탭에서는 하단에 고정된 `라이브 시작하기` CTA가 필요하며, 일반 사용자 채널 진입과 표시 조건이 달라야 한다. ## 3. Goals @@ -28,7 +28,8 @@ Figma 참조: - 기본 정렬은 `LATEST`로 호출하고, 서버 응답의 `sort`를 현재 선택 정렬 상태로 반영한다. - sort-bar는 `SodaLive/Sources/V2/CreatorChannel/Components/**` 아래에 생성해 라이브 탭 외 다른 탭에서도 재사용할 수 있게 한다. - 정렬 문구는 `I18n`에 ko/en/ja 다국어로 추가한다. -- 정렬 선택 UI는 BottomSheet 방식을 채택한다. +- sort-bar는 전체 개수가 `0` 이상으로 내려오면 `전체 23` 형식으로 개수를 표시한다. +- 정렬 선택 UI는 Figma와 같은 context popup 방식을 채택한다. - 현재 진행 중인 라이브는 크리에이터 채널 홈의 `CreatorChannelCurrentLiveSection` UI를 재사용한다. - 라이브 다시듣기 item은 Figma 기준으로 썸네일, 제목, duration, 가격/포인트/무료/19금/소장중/대여중 상태를 표시한다. - `hasNext == true`이면 스크롤 하단 진입 시 현재 응답의 `page + 1` 페이지를 추가 조회한다. @@ -140,25 +141,26 @@ enum ContentSort: String, Decodable, CaseIterable { ### 7.3 Sort-bar - 생성 위치: `SodaLive/Sources/V2/CreatorChannel/Components/**` - sort-bar는 현재 정렬명과 아래 방향 아이콘을 표시한다. +- `totalCount`가 `0` 이상이면 sort-bar 좌측에 `전체 {totalCount}`를 표시한다. +- `totalCount`가 `nil`이거나 음수이면 전체 개수 영역은 숨긴다. - Figma 기준 높이는 `52pt`, 좌우 horizontal padding은 기존 `SodaSpacing` 토큰으로 맞춘다. - 정렬명은 `I18n`에 추가한다. - sort-bar는 다른 탭에서도 재사용 가능하도록 `selectedSort`, `availableSorts`, `onTapSort` 같은 일반화된 입력을 받는다. - 이번 범위에서는 Figma의 `리스트형` view mode 옵션은 사용하지 않는다. ### 7.4 정렬 선택 UI -- 정렬 선택 UI는 BottomSheet 방식으로 구현한다. -- 선택 사유: - - 모바일 한 손 조작에서 화면 하단 sheet가 우측 상단 anchored menu보다 터치 접근성이 좋다. - - 정렬 옵션이 4개이고 이후 탭별 옵션 추가 가능성이 있어 sheet가 선택 영역과 dismiss 동작을 안정적으로 제공한다. - - iOS/Material 계열 최신 모바일 앱에서 필터/정렬 같은 목록 제어는 sheet 패턴이 일반적이며, context menu는 짧은 보조 action에 더 적합하다. -- 정렬 sheet 항목: +- 정렬 선택 UI는 Figma `290:9041`처럼 sort-bar 우측 정렬 버튼 아래에 붙는 context popup으로 구현한다. +- popup은 `Color.gray700` 1pt border, `14pt` corner radius, `Color.gray900` 기본 배경을 사용한다. +- 선택된 항목은 `Color.gray800` 배경으로 표시한다. +- popup 밖을 터치하거나 항목을 선택하면 닫힌다. +- 정렬 popup 항목: - `LATEST`: 최신순 - `POPULAR`: 인기순 - `PRICE_HIGH`: 가격 높은 순 - `PRICE_LOW`: 가격 낮은 순 - Figma `290:9041`에는 `추천순`이 있으나 API `ContentSort`에 없으므로 구현하지 않는다. - 현재 선택된 sort는 시각적으로 선택 상태를 표시한다. -- sort item 터치 시 sheet를 닫고 첫 페이지를 재조회한다. +- sort item 터치 시 popup을 닫고 첫 페이지를 재조회한다. ### 7.5 현재 진행 중인 라이브 - `currentLive != nil`이면 sort-bar 아래 또는 Figma 배치 기준 위치에 현재 진행 중인 라이브 UI를 표시한다. @@ -212,7 +214,7 @@ enum ContentSort: String, Decodable, CaseIterable { - sort-bar는 tab-bar 아래에 붙어 보이고, 목록 콘텐츠와 겹치지 않아야 한다. - Empty 상태에서는 sort-bar와 list가 보이지 않아야 한다. - 다시듣기 item의 썸네일, 제목, 가격/상태 영역은 긴 제목과 다국어 문구에서도 겹치지 않아야 한다. -- BottomSheet는 dim 또는 drag/dismiss 동작이 기존 앱 sheet 패턴과 일관되어야 한다. +- context popup은 sort-bar 우측 버튼 아래에 겹치지 않게 표시되고, 목록 콘텐츠 위에 떠야 한다. - 로딩 중에는 기존 목록을 과도하게 흔들지 않고, 첫 페이지 로딩과 추가 페이지 로딩 상태를 구분한다. 목록 교체와 append는 조회 성공 시에만 수행한다. ## 9. Technical Constraints @@ -229,7 +231,7 @@ enum ContentSort: String, Decodable, CaseIterable { - 라이브 탭 선택 시 `GET /api/v2/creator-channels/{creatorId}/live?page={page}&size={size}&sort={sort}`가 호출된다. - 기본 sort는 `LATEST`이며 sort-bar에는 `최신순`이 표시된다. - 최초 요청은 `page=0`, `size=20`으로 호출된다. -- sort-bar 터치 시 BottomSheet가 표시되고 `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 4개 옵션만 보인다. +- sort-bar 터치 시 context popup이 표시되고 `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 4개 옵션만 보인다. - 정렬 변경 시 첫 페이지부터 다시 조회되고, 조회 성공 시에만 목록이 새 정렬 결과로 교체된다. - `currentLive != nil`이면 홈 탭과 동일한 현재 라이브 UI가 라이브 탭에 표시된다. - `currentLive == nil`이고 `liveReplayContentCount == 0`이면 sort-bar/list 없이 `크리에이터가 라이브를 준비중입니다.\n기대해 주세요!` Empty 문구만 표시된다. @@ -242,7 +244,7 @@ enum ContentSort: String, Decodable, CaseIterable { - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`가 성공한다. ## 11. Decisions -- 정렬 메뉴 표시는 BottomSheet 방식을 채택한다. +- 정렬 메뉴 표시는 sort-bar 우측 버튼 아래 anchored context popup 방식을 채택한다. - 첫 페이지의 `page` 시작값은 `0`으로 한다. - 요청 `size` 기본값은 `20`으로 한다. - Figma 정렬 메뉴 예시에 있는 `추천순`은 API enum에 없으므로 제외한다. diff --git a/docs/20260704_크리에이터_채널_오디오_탭/plan-task.md b/docs/20260704_크리에이터_채널_오디오_탭/plan-task.md index 7f904ad4..9fc503d4 100644 --- a/docs/20260704_크리에이터_채널_오디오_탭/plan-task.md +++ b/docs/20260704_크리에이터_채널_오디오_탭/plan-task.md @@ -16,7 +16,7 @@ - 기존 `CreatorChannelView`의 공통 shell, header, sticky tab-bar, title bar 동작은 유지한다. - `CreatorChannelTab.audio` 콘텐츠만 신규 `CreatorChannelAudioTabView`로 교체한다. -- `ContentSort`, `CreatorChannelSortBar`, `CreatorChannelSortBottomSheet`, `CapsuleTabBar`, `CreatorChannelAudioContentListItem`을 재사용한다. +- `ContentSort`, `CreatorChannelSortBar`, 정렬 context popup, `CapsuleTabBar`, `CreatorChannelAudioContentListItem`을 재사용한다. - 전체 테마는 API 모델에 섞지 않고 오디오 탭 표시 모델에서만 합성한다. - `themeId == nil`이면 query parameter를 보내지 않는다. - `duration == nil` 또는 빈 문자열인 콘텐츠는 ViewModel에서 목록에 포함하지 않는다. @@ -134,6 +134,21 @@ - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` - 기대 결과: 빌드 성공. +### Phase 5: 공용 sort-bar 후속 변경 반영 + +- [x] **Task 5.1: 전체 개수와 context popup 재사용 확인** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortBar.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelSortContextPopup.swift` + - 작업 내용: + - `CreatorChannelSortBar`에 `audioContentCount`를 전달한다. + - 정렬 선택은 `.sheet`가 아닌 공용 context popup overlay를 사용한다. + - popup 항목은 기존 `ContentSort.allCases` 4개를 유지한다. + - 검증 기준: + - 실행 명령: `rg "audioContentCount|CreatorChannelSortContextPopup|isSortPopupPresented|\\.sheet\\(isPresented: \\$isSort" SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift SodaLive/Sources/V2/CreatorChannel/Components` + - 기대 결과: 오디오 탭의 count 전달과 popup 사용이 확인되고 정렬 sheet 사용처는 검색되지 않는다. + ## 검증 기록 - 2026-07-04: `get_design_context`와 `get_screenshot`으로 Figma `290:9015`, `290:9029`, `290:8965`, `665:19008`을 확인했다. @@ -141,3 +156,7 @@ - 2026-07-04: `git diff --check -- docs/20260704_크리에이터_채널_오디오_탭 SodaLive/Sources/V2/CreatorChannel SodaLive/Sources/I18n/I18n.swift SodaLive.xcodeproj/project.pbxproj` 실행 결과 출력 없이 성공했다. - 2026-07-04: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`는 `SodaLive.xcworkspace`를 workspace로 인식하지 못해 실패했다. - 2026-07-04: 대체로 `xcodebuild -project "SodaLive.xcodeproj" -scheme "SodaLive-dev" -configuration Debug build`를 샌드박스 밖에서 실행했다. SwiftPM 패키지 해석과 외부 패키지 컴파일은 진행됐으나 기존 외부 모듈 `Bootpay`, `BootpayUI`, `AgoraRtmKit`, `GoogleSignIn`, `YandexMobileAds`를 찾지 못해 앱 타깃 SwiftDriver 단계에서 실패했다. +- 2026-07-04: Figma `290:9034`, `290:9031`을 확인하고 Phase 5를 추가함. 오디오 탭도 공용 sort-bar 전체 개수 표시와 정렬 context popup 전환을 재사용하도록 범위를 갱신함. +- 2026-07-04: Phase 5 구현 완료. `CreatorChannelAudioTabView`에서 `audioContentCount`를 `CreatorChannelSortBar`에 전달하고 정렬 UI를 `.sheet`에서 `CreatorChannelSortContextPopup` overlay로 변경함. +- 2026-07-04: Phase 5 검증 완료. `rg "audioContentCount|CreatorChannelSortContextPopup|isSortPopupPresented|\\.sheet\\(isPresented: \\$isSort" ...`, `git diff --check -- docs/20260703_크리에이터_채널_라이브_탭 docs/20260704_크리에이터_채널_오디오_탭 SodaLive/Sources/V2/CreatorChannel`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`를 확인함. 샌드박스 밖 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`에서 `** BUILD SUCCEEDED **`를 확인함. +- 2026-07-04: 후속 반영. 정렬 popup 파일명을 `CreatorChannelSortContextPopup.swift`로 변경하고 Xcode project 참조와 문서 참조를 갱신함. 이전 정렬 파일명/타입 참조가 남지 않았음을 확인하고, 샌드박스 밖 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`에서 `** BUILD SUCCEEDED **`를 확인함. diff --git a/docs/20260704_크리에이터_채널_오디오_탭/prd.md b/docs/20260704_크리에이터_채널_오디오_탭/prd.md index 7f860576..5e074b31 100644 --- a/docs/20260704_크리에이터_채널_오디오_탭/prd.md +++ b/docs/20260704_크리에이터_채널_오디오_탭/prd.md @@ -21,7 +21,8 @@ Figma 참조: - `page`, `size`, `sort`, 선택된 `themeId`를 query parameter로 전달한다. - 기본값은 `page=0`, `size=20`, `sort=LATEST`, `themeId=nil`이다. - theme list는 `CapsuleTabBar`를 사용하고, 맨 앞에 전체 항목을 합성한다. -- 정렬 선택 방식은 라이브 탭과 동일한 `CreatorChannelSortBar` + `CreatorChannelSortBottomSheet`를 재사용한다. +- 정렬 선택 방식은 라이브 탭과 동일한 `CreatorChannelSortBar` + context popup을 재사용한다. +- sort-bar는 `audioContentCount`가 `0` 이상이면 `전체 {audioContentCount}`를 표시한다. - 콘텐츠 item은 `CreatorChannelAudioContentListItem`을 재사용한다. - 전체 테마이고 본인 채널이 아닐 때만 소장률 UI를 표시한다. - 본인 채널의 오디오 탭에는 `ic_new_upload_audio` 아이콘을 쓰는 하단 CTA를 표시한다. @@ -103,7 +104,7 @@ data class CreatorChannelAudioThemeResponse( - 오디오 탭 진입 시 `GET /api/v2/creator-channels/{creatorId}/audio?page=0&size=20&sort=LATEST`가 호출되고, 전체 선택 상태에서는 `themeId`가 전송되지 않는다. - `themes` 앞에 전체 탭이 표시되고, 응답 `themeId == nil`이면 전체가 선택된다. - 테마 선택 시 선택한 `themeId`로 첫 페이지를 다시 조회한다. -- 정렬 선택 UI와 동작은 라이브 탭과 동일하다. +- 정렬 선택 UI는 BottomSheet가 아니라 sort-bar 우측 버튼 아래 anchored context popup으로 표시되고, 동작은 라이브 탭과 동일하다. - 본인 채널이 아니고 전체 테마일 때만 소장률 UI가 표시된다. - 본인 채널이거나 테마 필터 상태이면 소장률 UI가 표시되지 않는다. - `duration`이 없는 콘텐츠는 목록에 표시되지 않는다. @@ -113,9 +114,10 @@ data class CreatorChannelAudioThemeResponse( ## 7. Technical Constraints - 기능 변경은 `SodaLive/Sources/V2/CreatorChannel/**` 하위에서 해결한다. - 오디오 탭 전용 View, ViewModel, Repository, API, 모델은 `SodaLive/Sources/V2/CreatorChannel/Audio/**` 아래에 둔다. -- 공용 item, sort bar, sort bottom sheet, `CapsuleTabBar`는 기존 구현을 재사용한다. +- 공용 item, sort bar, sort context popup, `CapsuleTabBar`는 기존 구현을 재사용한다. - 신규 문구는 `SodaLive/Sources/I18n/I18n.swift`에 ko/en/ja를 추가한다. ## 8. Verification Notes - 2026-07-04: Figma `get_design_context`와 `get_screenshot`으로 `290:9015`, `290:9029`, `290:8965`, `665:19008`을 확인했다. - 2026-07-04: `CreatorChannelLiveTabView`, `CreatorChannelLiveViewModel`, `CreatorChannelSortBar`, `CapsuleTabBar`, `CreatorChannelAudioContentListItem` 기존 구현을 확인해 재사용 경계를 확정했다. +- 2026-07-04: Figma `290:9034`, `290:9031`을 추가 확인해 sort-bar 전체 개수 표시와 정렬 context popup 전환 요구사항을 반영했다.