feat(creator): 오디오 탭을 추가한다

This commit is contained in:
Yu Sung
2026-07-04 16:40:35 +09:00
parent 6600cd43c1
commit c4c18341a6
12 changed files with 955 additions and 77 deletions

View File

@@ -0,0 +1,76 @@
import Foundation
import SwiftUI
struct CreatorChannelAudioOwnershipRateView: View {
let purchasedAudioContentRate: Double
let purchasedAudioContentCount: Int
let paidAudioContentCount: Int
private var progress: CGFloat {
CGFloat(max(0, min(purchasedAudioContentRate / 100, 1)))
}
private var rateText: String {
let rounded = purchasedAudioContentRate.rounded()
if rounded == purchasedAudioContentRate {
return "\(Int(rounded))%"
}
return String(format: "%.1f%%", purchasedAudioContentRate)
}
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
HStack(alignment: .center, spacing: SodaSpacing.s4) {
(
Text(I18n.CreatorChannelAudio.ownershipPrefix)
.foregroundColor(.white)
+ Text(rateText)
.foregroundColor(Color.soda400)
+ Text(I18n.CreatorChannelAudio.ownershipSuffix)
.foregroundColor(.white)
)
.appFont(size: 14, weight: .medium)
.lineLimit(1)
Spacer(minLength: SodaSpacing.s8)
(
Text(purchasedAudioContentCount.comma())
.foregroundColor(.white)
+ Text("/\(paidAudioContentCount.comma())\(I18n.CreatorChannelAudio.countUnit)")
.foregroundColor(Color.gray500)
)
.appFont(size: 14, weight: .medium)
.lineLimit(1)
}
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule()
.fill(Color.gray800)
Capsule()
.fill(Color.soda400)
.frame(width: proxy.size.width * progress)
}
}
.frame(height: 4)
}
.padding(SodaSpacing.s14)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
.padding(.horizontal, SodaSpacing.s14)
}
}
struct CreatorChannelAudioOwnershipRateView_Previews: PreviewProvider {
static var previews: some View {
CreatorChannelAudioOwnershipRateView(
purchasedAudioContentRate: 58,
purchasedAudioContentCount: 12,
paidAudioContentCount: 43
)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,41 @@
import SwiftUI
struct CreatorChannelAudioUploadButton: View {
let action: () -> Void
var body: some View {
VStack {
Button(action: action) {
HStack(spacing: SodaSpacing.s6) {
Image("ic_new_upload_audio")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text(I18n.CreatorChannelHome.uploadAudioContent)
.appFont(size: 16, weight: .bold)
.foregroundColor(.white)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.frame(height: 52)
.padding(.horizontal, SodaSpacing.s14)
.background(Color.soda400)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
.padding(.top, SodaSpacing.s14)
.padding(.bottom, 34)
.background(Color.black)
}
}
struct CreatorChannelAudioUploadButton_Previews: PreviewProvider {
static var previews: some View {
CreatorChannelAudioUploadButton(action: {})
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,108 @@
import SwiftUI
struct CreatorChannelAudioTabView: View {
let creatorId: Int
let isOwnCreatorChannel: Bool
let onTapContent: (Int) -> Void
@StateObject private var viewModel = CreatorChannelAudioViewModel()
@State private var isSortSheetPresented = false
var body: some View {
content
.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) {
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
)
.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)
}
}
}
private var shouldShowOwnershipRate: Bool {
guard isOwnCreatorChannel == false else { return false }
return viewModel.isAllThemeSelected
}
private var isEmptyState: Bool {
guard viewModel.hasLoaded, let response = viewModel.response else { return false }
return response.audioContentCount == 0
}
private var emptyStateView: some View {
Text(I18n.CreatorChannelAudio.emptyMessage)
.appFont(.body3)
.foregroundColor(Color.gray500)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
}
}
struct CreatorChannelAudioTabView_Previews: PreviewProvider {
static var previews: some View {
CreatorChannelAudioTabView(
creatorId: 1,
isOwnCreatorChannel: false,
onTapContent: { _ in }
)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,173 @@
import Foundation
import Combine
struct CreatorChannelAudioThemeItem: Hashable {
let themeId: Int?
let themeName: String
}
final class CreatorChannelAudioViewModel: ObservableObject {
private let repository = CreatorChannelAudioRepository()
private var subscription = Set<AnyCancellable>()
private let pageSize = 20
private var latestRequestId = 0
@Published var isLoading = false
@Published var isLoadingNextPage = false
@Published var response: CreatorChannelAudioTabResponse?
@Published var audioContents = [CreatorChannelAudioContentResponse]()
@Published var selectedSort: ContentSort = .latest
@Published var selectedTheme = CreatorChannelAudioThemeItem.all
@Published var page = 0
@Published var size = 20
@Published var hasNext = false
@Published var hasLoaded = false
@Published var errorMessage = ""
@Published var isShowPopup = false
var displayThemes: [CreatorChannelAudioThemeItem] {
let themes = response?.themes.map {
CreatorChannelAudioThemeItem(themeId: $0.themeId, themeName: $0.themeName)
} ?? []
return [CreatorChannelAudioThemeItem.all] + themes
}
var isAllThemeSelected: Bool {
selectedTheme.themeId == nil
}
func fetchFirstPage(creatorId: Int) {
fetchAudio(creatorId: creatorId, page: 0, isNextPage: false)
}
func fetchNextPageIfNeeded(creatorId: Int, currentItem: CreatorChannelAudioContentResponse) {
guard audioContents.last?.audioContentId == currentItem.audioContentId else { return }
guard hasNext, isLoadingNextPage == false, isLoading == false else { return }
fetchAudio(creatorId: creatorId, page: page + 1, isNextPage: true)
}
func selectSort(_ sort: ContentSort, creatorId: Int) {
guard selectedSort != sort else { return }
selectedSort = sort
fetchFirstPage(creatorId: creatorId)
}
func selectTheme(_ theme: CreatorChannelAudioThemeItem, creatorId: Int) {
guard selectedTheme != theme else { return }
selectedTheme = theme
fetchFirstPage(creatorId: creatorId)
}
private func fetchAudio(creatorId: Int, page requestPage: Int, isNextPage: Bool) {
latestRequestId += 1
let requestId = latestRequestId
if isNextPage {
isLoadingNextPage = true
} else {
isLoading = true
}
repository.getAudio(
creatorId: creatorId,
page: requestPage,
size: pageSize,
sort: selectedSort,
themeId: selectedTheme.themeId
)
.sink { [weak self] result in
guard let self else { return }
guard requestId == self.latestRequestId 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 }
guard requestId == self.latestRequestId else { return }
var shouldFetchNextPage = false
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<CreatorChannelAudioTabResponse>.self, from: response.data)
if let data = decoded.data, decoded.success {
shouldFetchNextPage = 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
if shouldFetchNextPage {
self.fetchAudio(creatorId: creatorId, page: self.page + 1, isNextPage: true)
}
}
.store(in: &subscription)
}
private func applySuccessState(_ data: CreatorChannelAudioTabResponse, isNextPage: Bool) -> Bool {
response = data
selectedSort = data.sort
selectedTheme = selectedTheme(from: data)
page = data.page
size = data.size
hasNext = data.hasNext
let visibleAudioContents = data.audioContents.filter {
guard let duration = $0.duration?.trimmingCharacters(in: .whitespacesAndNewlines) else { return false }
return duration.isEmpty == false
}
if isNextPage {
audioContents.append(contentsOf: visibleAudioContents)
} else {
audioContents = visibleAudioContents
}
return visibleAudioContents.isEmpty && data.hasNext
}
private func selectedTheme(from data: CreatorChannelAudioTabResponse) -> CreatorChannelAudioThemeItem {
guard let themeId = data.themeId else {
return .all
}
if let theme = data.themes.first(where: { $0.themeId == themeId }) {
return CreatorChannelAudioThemeItem(themeId: theme.themeId, themeName: theme.themeName)
}
return CreatorChannelAudioThemeItem(themeId: themeId, themeName: selectedTheme.themeName)
}
private func applyFailureState() {
errorMessage = I18n.Common.commonError
isShowPopup = true
hasLoaded = true
isLoading = false
isLoadingNextPage = false
}
}
private extension CreatorChannelAudioThemeItem {
static var all: CreatorChannelAudioThemeItem {
CreatorChannelAudioThemeItem(themeId: nil, themeName: I18n.CreatorChannelAudio.totalLabel)
}
}

View File

@@ -0,0 +1,20 @@
import Foundation
struct CreatorChannelAudioTabResponse: Decodable {
let audioContentCount: Int
let paidAudioContentCount: Int
let purchasedAudioContentCount: Int
let purchasedAudioContentRate: Double
let themes: [CreatorChannelAudioThemeResponse]
let audioContents: [CreatorChannelAudioContentResponse]
let sort: ContentSort
let themeId: Int?
let page: Int
let size: Int
let hasNext: Bool
}
struct CreatorChannelAudioThemeResponse: Decodable, Hashable {
let themeId: Int
let themeName: String
}

View File

@@ -0,0 +1,48 @@
import Foundation
import Moya
enum CreatorChannelAudioApi {
case getAudio(creatorId: Int, page: Int, size: Int, sort: ContentSort, themeId: Int?)
}
extension CreatorChannelAudioApi: TargetType {
var baseURL: URL {
return URL(string: BASE_URL)!
}
var path: String {
switch self {
case .getAudio(let creatorId, _, _, _, _):
return "/api/v2/creator-channels/\(creatorId)/audio"
}
}
var method: Moya.Method {
switch self {
case .getAudio:
return .get
}
}
var task: Moya.Task {
switch self {
case .getAudio(_, let page, let size, let sort, let themeId):
var parameters: [String: Any] = [
"page": page,
"size": size,
"sort": sort.rawValue
]
if let themeId {
parameters["themeId"] = themeId
}
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
}
}
var headers: [String: String]? {
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
}
}

View File

@@ -0,0 +1,27 @@
import Foundation
import Combine
import CombineMoya
import Moya
final class CreatorChannelAudioRepository {
private let api = MoyaProvider<CreatorChannelAudioApi>()
func getAudio(
creatorId: Int,
page: Int,
size: Int,
sort: ContentSort,
themeId: Int?
) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getAudio(
creatorId: creatorId,
page: page,
size: size,
sort: sort,
themeId: themeId
)
)
}
}