feat(creator): 오디오 탭을 추가한다
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))"]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,15 @@ struct CreatorChannelView: View {
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
}
|
||||
|
||||
if isOwnCreatorChannel && viewModel.selectedTab == .audio && !viewModel.isLoading {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
CreatorChannelAudioUploadButton(action: showAudioContentUpload)
|
||||
}
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
}
|
||||
|
||||
if isShowAuthConfirmView {
|
||||
authConfirmDialog
|
||||
}
|
||||
@@ -207,6 +216,12 @@ struct CreatorChannelView: View {
|
||||
onTapLive: showLiveDetail,
|
||||
onTapContent: showContentDetail
|
||||
)
|
||||
} else if viewModel.selectedTab == .audio {
|
||||
CreatorChannelAudioTabView(
|
||||
creatorId: creatorId,
|
||||
isOwnCreatorChannel: isOwnCreatorChannel,
|
||||
onTapContent: showContentDetail
|
||||
)
|
||||
} else if viewModel.selectedTab != .home {
|
||||
CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title)
|
||||
} else if let response = viewModel.response {
|
||||
|
||||
Reference in New Issue
Block a user