feat(content): 랭킹 탭을 추가한다

This commit is contained in:
Yu Sung
2026-07-06 20:19:27 +09:00
parent bae92eba4b
commit d129583d3b
15 changed files with 1322 additions and 1 deletions

View File

@@ -3053,6 +3053,22 @@ If you block this user, the following features will be restricted.
static var tabTitle: String {
pick(ko: "랭킹", en: "Ranking", ja: "ランキング")
}
static var emptyStateMessage: String {
pick(
ko: "랭킹 콘텐츠가 아직 없어요.",
en: "No ranking content yet.",
ja: "ランキングコンテンツはまだありません。"
)
}
static var loadFailedMessage: String {
pick(
ko: "랭킹 콘텐츠를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
en: "Unable to load ranking content.\nPlease try again later.",
ja: "ランキングコンテンツを読み込めませんでした。\nしばらくしてからもう一度お試しください。"
)
}
}
enum MainContentAll {

View File

@@ -48,7 +48,7 @@ struct MainContentView: View {
onTapSeries: onTapSeries
)
case .ranking:
MainPlaceholderTabView(title: MainContentTab.ranking.title)
MainContentRankingView(onTapContent: onTapContent)
case .all:
MainContentAllView(
viewModel: allViewModel,

View File

@@ -0,0 +1,10 @@
import SwiftUI
struct MainContentAudioRankingEmptyStateView: View {
let message: String
var body: some View {
MainContentAudioEmptyStateView(message: message)
.background(Color.black)
}
}

View File

@@ -0,0 +1,75 @@
import SwiftUI
struct MainContentAudioRankingRow: View {
let item: AudioRankingItemResponse
let showRankChange: Bool
let onTapContent: (Int) -> Void
var body: some View {
Button {
onTapContent(item.contentId)
} label: {
GeometryReader { proxy in
let rowHeight = proxy.size.height
let imageSide = rowHeight * 0.8
HStack(alignment: .center, spacing: SodaSpacing.s14) {
rankColumn
DownsampledKFImage(
url: URL(string: item.coverImageUrl ?? ""),
size: CGSize(width: imageSide, height: imageSide)
)
.background(Color.gray800)
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
VStack(alignment: .leading, spacing: SodaSpacing.s4) {
Text(item.title)
.appFont(.heading4)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
Text(item.creatorNickname)
.appFont(.body4)
.foregroundColor(Color.gray400)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, SodaSpacing.s14)
.padding(.vertical, rowHeight * 0.1)
}
.aspectRatio(3.74, contentMode: .fit)
.background(Color.black)
}
.buttonStyle(.plain)
}
private var rankColumn: some View {
VStack(alignment: .center, spacing: 0) {
Text("\(item.rank)")
.appFont(size: 40, family: .pattaya)
.foregroundStyle(
LinearGradient(
colors: [.white, Color.gray200],
startPoint: .top,
endPoint: .bottom
)
)
.shadow(color: Color.black.opacity(0.48), radius: 2)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .center)
MainContentRankChangeBadge(
showRankChange: showRankChange,
rankChange: item.rankChange,
isNew: item.isNew
)
.frame(maxWidth: .infinity, alignment: .center)
}
.frame(width: 49, alignment: .center)
}
}

View File

@@ -0,0 +1,139 @@
import SwiftUI
struct MainContentAudioRankingThumbnailCard: View {
let item: AudioRankingItemResponse
let showRankChange: Bool
let columnsPerRow: Int
let onTapContent: (Int) -> Void
init(
item: AudioRankingItemResponse,
showRankChange: Bool,
columnsPerRow: Int,
onTapContent: @escaping (Int) -> Void = { _ in }
) {
self.item = item
self.showRankChange = showRankChange
self.columnsPerRow = columnsPerRow
self.onTapContent = onTapContent
}
var body: some View {
Button {
onTapContent(item.contentId)
} label: {
GeometryReader { proxy in
let side = min(proxy.size.width, proxy.size.height)
ZStack(alignment: .topLeading) {
coverImage(size: CGSize(width: side, height: side))
LinearGradient(
colors: [Color.black.opacity(0), Color.black.opacity(0.72)],
startPoint: .top,
endPoint: .bottom
)
rankColumn(side: side)
.padding(.leading, rankColumnLeadingPadding(side))
.padding(.top, rankColumnTopPadding(side))
VStack(spacing: SodaSpacing.s4) {
Spacer()
Text(item.title)
.appFont(size: titleFontSize(side), weight: .bold)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
Text(item.creatorNickname)
.appFont(size: creatorFontSize(side), weight: .regular)
.foregroundColor(Color.gray200)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, textHorizontalPadding(side))
.padding(.bottom, textBottomPadding(side))
}
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
.aspectRatio(1, contentMode: .fit)
}
.buttonStyle(.plain)
}
private func coverImage(size: CGSize) -> some View {
DownsampledKFImage(url: URL(string: item.coverImageUrl ?? ""), size: size)
.background(Color.gray800)
}
private func rankColumn(side: CGFloat) -> some View {
VStack(alignment: .center, spacing: rankColumnSpacing) {
Text("\(item.rank)")
.appFont(size: rankFontSize(side), family: .pattaya)
.foregroundStyle(
LinearGradient(
colors: [.white, Color.gray200],
startPoint: .top,
endPoint: .bottom
)
)
.shadow(color: Color.black.opacity(0.48), radius: 2)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .center)
MainContentRankChangeBadge(
showRankChange: showRankChange,
rankChange: item.rankChange,
isNew: item.isNew
)
.frame(maxWidth: .infinity, alignment: .center)
}
.frame(width: rankColumnWidth(side), alignment: .center)
}
private var rankColumnSpacing: CGFloat {
switch columnsPerRow {
case 2:
return -10
case 3:
return -5
default:
return -10
}
}
private func rankFontSize(_ side: CGFloat) -> CGFloat {
max(32, side * 0.26)
}
private func titleFontSize(_ side: CGFloat) -> CGFloat {
max(14, side * 0.085)
}
private func creatorFontSize(_ side: CGFloat) -> CGFloat {
max(12, side * 0.07)
}
private func rankColumnWidth(_ side: CGFloat) -> CGFloat {
max(49, side * 0.19)
}
private func rankColumnLeadingPadding(_ side: CGFloat) -> CGFloat {
max(0, side * 0.035)
}
private func rankColumnTopPadding(_ side: CGFloat) -> CGFloat {
max(-8, side * 0.02)
}
private func textHorizontalPadding(_ side: CGFloat) -> CGFloat {
max(SodaSpacing.s8, side * 0.08)
}
private func textBottomPadding(_ side: CGFloat) -> CGFloat {
max(SodaSpacing.s12, side * 0.08)
}
}

View File

@@ -0,0 +1,93 @@
import SwiftUI
struct MainContentAudioRankingTopCard: View {
let item: AudioRankingItemResponse
let showRankChange: Bool
let onTapContent: (Int) -> Void
var body: some View {
Button {
onTapContent(item.contentId)
} label: {
GeometryReader { proxy in
let width = proxy.size.width
let imageSide = min(width * 0.42, proxy.size.height * 0.65)
ZStack(alignment: .topLeading) {
DownsampledKFImage(
url: URL(string: item.coverImageUrl ?? ""),
size: CGSize(width: width, height: proxy.size.height)
)
.background(Color.gray800)
.blur(radius: 10)
Color.black.opacity(0.1)
LinearGradient(
colors: [Color.black.opacity(0), Color.black.opacity(0.72)],
startPoint: .top,
endPoint: .bottom
)
rankColumn
.padding(.leading, SodaSpacing.s20)
.padding(.top, SodaSpacing.s4)
VStack(spacing: SodaSpacing.s8) {
DownsampledKFImage(
url: URL(string: item.coverImageUrl ?? ""),
size: CGSize(width: imageSide, height: imageSide)
)
.background(Color.gray800)
.aspectRatio(1, contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
.shadow(color: Color.black.opacity(0.2), radius: 15)
VStack(spacing: 0) {
Text(item.title)
.appFont(size: 22, weight: .bold)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
Text(item.creatorNickname)
.appFont(.caption1)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity)
}
.padding(.horizontal, SodaSpacing.s20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
.aspectRatio(1.57, contentMode: .fit)
}
.buttonStyle(.plain)
}
private var rankColumn: some View {
VStack(alignment: .center, spacing: -20) {
Text("\(item.rank)")
.appFont(size: 96, family: .pattaya)
.foregroundStyle(
LinearGradient(
colors: [.white, Color.gray200],
startPoint: .top,
endPoint: .bottom
)
)
.shadow(color: Color.black.opacity(0.48), radius: 2)
.lineLimit(1)
MainContentRankChangeBadge(
showRankChange: showRankChange,
rankChange: item.rankChange,
isNew: item.isNew
)
}
.frame(width: 86, alignment: .center)
}
}

View File

@@ -0,0 +1,48 @@
import SwiftUI
struct MainContentRankChangeBadge: View {
let showRankChange: Bool
let rankChange: Int?
let isNew: Bool
var body: some View {
if showRankChange {
if isNew {
Image("ic_rank_new")
.resizable()
.scaledToFit()
.frame(width: 28, height: 12)
} else if let rankChange {
HStack(spacing: 2) {
switch rankChange {
case let value where value > 0:
rankChangeText(abs(value))
rankChangeIcon("ic_rank_caret_increase")
case let value where value < 0:
rankChangeText(abs(value))
rankChangeIcon("ic_rank_caret_decrease")
default:
rankChangeIcon("ic_rank_caret_stay")
}
}
.padding(.horizontal, 4)
.background(Color.gray900)
.cornerRadius(4)
}
}
}
private func rankChangeText(_ value: Int) -> some View {
Text("\(value)")
.appFont(.caption2)
.foregroundColor(Color.gray400)
.lineLimit(1)
}
private func rankChangeIcon(_ name: String) -> some View {
Image(name)
.resizable()
.scaledToFit()
.frame(width: 8, height: 8)
}
}

View File

@@ -0,0 +1,111 @@
import SwiftUI
struct MainContentRankingView: View {
let onTapContent: (Int) -> Void
@StateObject private var viewModel = MainContentRankingViewModel()
init(onTapContent: @escaping (Int) -> Void = { _ in }) {
self.onTapContent = onTapContent
}
var body: some View {
ZStack {
Color.black
.ignoresSafeArea()
if viewModel.isLoading && !viewModel.hasLoaded {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else if viewModel.shouldShowEmptyState {
MainContentAudioRankingEmptyStateView(message: viewModel.emptyStateMessage)
} else {
rankingContent
}
}
.onAppear {
if !viewModel.hasLoaded {
viewModel.fetchRankings()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
private var rankingContent: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
if let topItem {
MainContentAudioRankingTopCard(
item: topItem,
showRankChange: viewModel.showRankChange,
onTapContent: onTapContent
)
}
if !secondToSeventhRankItems.isEmpty {
LazyVGrid(columns: twoColumns, alignment: .center, spacing: SodaSpacing.s8) {
ForEach(secondToSeventhRankItems) { item in
MainContentAudioRankingThumbnailCard(
item: item,
showRankChange: viewModel.showRankChange,
columnsPerRow: 2,
onTapContent: onTapContent
)
}
}
}
if !eighthToTenthRankItems.isEmpty {
LazyVGrid(columns: threeColumns, alignment: .center, spacing: SodaSpacing.s8) {
ForEach(eighthToTenthRankItems) { item in
MainContentAudioRankingThumbnailCard(
item: item,
showRankChange: viewModel.showRankChange,
columnsPerRow: 3,
onTapContent: onTapContent
)
}
}
}
if !rowRankItems.isEmpty {
VStack(spacing: SodaSpacing.s8) {
ForEach(rowRankItems) { item in
MainContentAudioRankingRow(
item: item,
showRankChange: viewModel.showRankChange,
onTapContent: onTapContent
)
}
}
}
}
.padding(.horizontal, SodaSpacing.s8)
.padding(.vertical, SodaSpacing.s20)
}
}
private var twoColumns: [GridItem] {
[GridItem(.flexible()), GridItem(.flexible())]
}
private var threeColumns: [GridItem] {
[GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
}
private var topItem: AudioRankingItemResponse? {
viewModel.items.first { $0.rank == 1 }
}
private var secondToSeventhRankItems: [AudioRankingItemResponse] {
viewModel.items.filter { 2...7 ~= $0.rank }
}
private var eighthToTenthRankItems: [AudioRankingItemResponse] {
viewModel.items.filter { 8...10 ~= $0.rank }
}
private var rowRankItems: [AudioRankingItemResponse] {
viewModel.items.filter { 11...20 ~= $0.rank }
}
}

View File

@@ -0,0 +1,80 @@
import Foundation
import Combine
final class MainContentRankingViewModel: ObservableObject {
private let repository = MainContentRankingRepository()
private var subscription = Set<AnyCancellable>()
@Published var isLoading = false
@Published var isShowPopup = false
@Published var errorMessage = ""
@Published var items = [AudioRankingItemResponse]()
@Published var showRankChange = false
@Published var hasLoaded = false
var emptyStateMessage: String {
if !errorMessage.isEmpty {
return errorMessage
}
return I18n.MainContentRanking.emptyStateMessage
}
var shouldShowEmptyState: Bool {
hasLoaded && !isLoading && items.isEmpty
}
func fetchRankings() {
guard !isLoading else { return }
isLoading = true
repository.getAudioRankings()
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.items = []
self.showRankChange = false
self.errorMessage = I18n.MainContentRanking.loadFailedMessage
self.isShowPopup = true
self.isLoading = false
self.hasLoaded = true
}
} receiveValue: { [weak self] response in
guard let self else { return }
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<AudioRankingResponse>.self, from: responseData)
if let data = decoded.data, decoded.success, !data.items.isEmpty {
self.errorMessage = ""
self.items = Array(data.items.prefix(20))
self.showRankChange = data.showRankChange
} else {
self.items = []
self.showRankChange = false
self.errorMessage = decoded.success ? "" : I18n.MainContentRanking.loadFailedMessage
self.isShowPopup = !decoded.success
}
} catch {
ERROR_LOG(error.localizedDescription)
self.items = []
self.showRankChange = false
self.errorMessage = I18n.MainContentRanking.loadFailedMessage
self.isShowPopup = true
}
self.isLoading = false
self.hasLoaded = true
}
.store(in: &subscription)
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
struct AudioRankingResponse: Decodable {
let showRankChange: Bool
let type: AudioRankingType
let items: [AudioRankingItemResponse]
}
enum AudioRankingType: String, Decodable, CaseIterable, Hashable {
case weeklyPopular = "WEEKLY_POPULAR"
case rising = "RISING"
case revenue = "REVENUE"
case salesCount = "SALES_COUNT"
case commentCount = "COMMENT_COUNT"
case likeCount = "LIKE_COUNT"
}
struct AudioRankingItemResponse: Decodable, Identifiable {
let contentId: Int
let title: String
let creatorNickname: String
let rank: Int
let rankChange: Int?
let isNew: Bool
let coverImageUrl: String?
var id: Int { contentId }
}

View File

@@ -0,0 +1,38 @@
import Foundation
import Moya
enum MainContentRankingApi {
case getAudioRankings
}
extension MainContentRankingApi: TargetType {
var baseURL: URL {
return URL(string: BASE_URL)!
}
var path: String {
switch self {
case .getAudioRankings:
return "/api/v2/audio/rankings"
}
}
var method: Moya.Method {
switch self {
case .getAudioRankings:
return .get
}
}
var task: Moya.Task {
switch self {
case .getAudioRankings:
return .requestPlain
}
}
var headers: [String: String]? {
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
}
}

View File

@@ -0,0 +1,13 @@
import Foundation
import Combine
import CombineMoya
import Moya
final class MainContentRankingRepository {
private let api = MoyaProvider<MainContentRankingApi>()
func getAudioRankings() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getAudioRankings)
}
}