feat(content): 전체 탭을 추가한다

This commit is contained in:
Yu Sung
2026-07-06 16:28:33 +09:00
parent b42ca61ce4
commit bae92eba4b
14 changed files with 1233 additions and 1 deletions

View File

@@ -8,7 +8,7 @@
import Foundation
import Combine
enum SeriesPublishedDaysOfWeek: String, Encodable {
enum SeriesPublishedDaysOfWeek: String, Encodable, Decodable, CaseIterable, Hashable {
case SUN, MON, TUE, WED, THU, FRI, SAT, RANDOM
}

View File

@@ -3049,6 +3049,57 @@ If you block this user, the following features will be restricted.
}
enum MainContentRanking {
static var tabTitle: String {
pick(ko: "랭킹", en: "Ranking", ja: "ランキング")
}
}
enum MainContentAll {
static var tabTitle: String {
pick(ko: "전체", en: "All", ja: "すべて")
}
static var rangeLabel: String {
pick(ko: "전체", en: "All", ja: "すべて")
}
static var emptyStateMessage: String {
pick(
ko: "콘텐츠가 아직 없어요.",
en: "No content yet.",
ja: "コンテンツはまだありません。"
)
}
static var loadFailedMessage: String {
pick(
ko: "콘텐츠를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
en: "Unable to load content.\nPlease try again later.",
ja: "コンテンツを読み込めませんでした。\nしばらくしてからもう一度お試しください。"
)
}
enum TypeChip {
static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
static var series: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") }
static var original: String { pick(ko: "오리지널", en: "Original", ja: "オリジナル") }
static var free: String { pick(ko: "무료", en: "Free", ja: "無料") }
static var point: String { pick(ko: "포인트", en: "Point", ja: "ポイント") }
}
enum DayOfWeek {
static var mon: String { pick(ko: "", en: "MON", ja: "") }
static var tue: String { pick(ko: "", en: "TUE", ja: "") }
static var wed: String { pick(ko: "", en: "WED", ja: "") }
static var thu: String { pick(ko: "", en: "THU", ja: "") }
static var fri: String { pick(ko: "", en: "FRI", ja: "") }
static var sat: String { pick(ko: "", en: "SAT", ja: "") }
static var sun: String { pick(ko: "", en: "SUN", ja: "") }
static var random: String { pick(ko: "기타", en: "OTHER", ja: "その他") }
}
}
enum Home {
static var liveNowSectionTitle: String {
pick(ko: "지금 라이브중", en: "Live now", ja: "ライブ配信中")

View File

@@ -189,6 +189,20 @@ extension AudioContentThumbnailCardItem {
isOriginalSeries: audioCard.isOriginalSeries
)
}
init(mainContentAudio: MainContentAudioResponse) {
self.init(
id: mainContentAudio.audioContentId,
title: mainContentAudio.title,
subtitle: mainContentAudio.creatorNickname,
imageUrl: mainContentAudio.imageUrl,
price: mainContentAudio.price,
isAdult: mainContentAudio.isAdult,
isPointAvailable: mainContentAudio.isPointAvailable,
isFirstContent: mainContentAudio.isFirstContent,
isOriginalSeries: mainContentAudio.isOriginalSeries
)
}
}
struct AudioContentThumbnailCard_Previews: PreviewProvider {

View File

@@ -0,0 +1,78 @@
import SwiftUI
struct MainContentSeriesThumbnailCard: View {
let item: MainContentSeriesResponse
let width: CGFloat
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
thumbnail
labelContent
}
.frame(width: width, alignment: .leading)
}
private var thumbnail: some View {
ZStack(alignment: .topLeading) {
DownsampledKFImage(
url: item.coverImageUrl.flatMap(URL.init(string:)),
size: CGSize(width: width, height: width * 1.41)
)
.background(Color.gray800)
if item.isOriginal {
originalTag
}
HStack(spacing: 0) {
Spacer(minLength: 0)
if item.isAdult {
CreatorChannelAudioAdultTag()
.padding(.top, SodaSpacing.s6)
.padding(.trailing, SodaSpacing.s6)
}
}
}
.frame(width: width, height: width * 1.41)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
private var originalTag: some View {
HStack(spacing: SodaSpacing.s4) {
Image("ic_series_original")
.resizable()
.scaledToFit()
.frame(width: 14, height: 14)
Image("img_new_only")
.resizable()
.scaledToFit()
.frame(height: 14)
}
.padding(.horizontal, SodaSpacing.s8)
.padding(.vertical, 5)
.background(Color.gray900)
}
private var labelContent: some View {
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.appFont(size: 16, weight: .bold)
.foregroundColor(Color.white)
.lineLimit(1)
.truncationMode(.tail)
Text(item.creatorNickname)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.gray500)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(width: width - (SodaSpacing.s4 * 2), alignment: .leading)
.padding(.horizontal, SodaSpacing.s4)
.frame(width: width, alignment: .leading)
}
}

View File

@@ -0,0 +1,200 @@
import SwiftUI
struct MainContentAllView: View {
@ObservedObject var viewModel: MainContentAllViewModel
let onTapContent: (Int) -> Void
let onTapSeries: (Int) -> Void
@State private var isSortPopupPresented = false
private let columns = [
GridItem(.flexible(), spacing: SodaSpacing.s4),
GridItem(.flexible(), spacing: SodaSpacing.s4),
GridItem(.flexible(), spacing: SodaSpacing.s4)
]
var body: some View {
ZStack(alignment: .topTrailing) {
Color.black
.ignoresSafeArea()
content
if isSortPopupPresented {
Color.black
.opacity(0.001)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture {
isSortPopupPresented = false
}
CreatorChannelSortContextPopup(
selectedSort: viewModel.selectedSort,
sorts: viewModel.allowedSorts
) { sort in
viewModel.selectSort(sort)
isSortPopupPresented = false
}
.padding(.top, viewModel.selectedType == .series ? 156 : 104)
.padding(.trailing, SodaSpacing.s14)
.zIndex(1)
}
}
.onAppear {
viewModel.fetchFirstPageIfNeeded()
}
.onChange(of: viewModel.selectedSort) { _ in
isSortPopupPresented = false
}
.onChange(of: viewModel.selectedType) { _ in
isSortPopupPresented = false
}
.onDisappear {
isSortPopupPresented = false
}
.sodaToast(
isPresented: $viewModel.isShowPopup,
message: viewModel.errorMessage,
autohideIn: 2
)
}
private var content: some View {
VStack(spacing: 0) {
typeFilter
if viewModel.selectedType == .series {
dayOfWeekFilter
}
sortBar
if viewModel.isLoading && viewModel.hasLoaded == false {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.shouldShowEmptyState {
MainContentAudioEmptyStateView(message: I18n.MainContentAll.emptyStateMessage)
} else {
ScrollView(showsIndicators: false) {
gridContent
.padding(.horizontal, SodaSpacing.s14)
.padding(.top, SodaSpacing.s8)
.padding(.bottom, SodaSpacing.s20)
if viewModel.isLoadingNextPage {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.vertical, SodaSpacing.s20)
}
}
}
}
}
private var typeFilter: some View {
CapsuleTabBar(
items: MainContentAllType.allCases,
selectedItem: Binding(
get: { viewModel.selectedType },
set: { viewModel.selectType($0) }
),
title: { $0.title }
)
}
private var dayOfWeekFilter: some View {
HStack(spacing: 0) {
ForEach(SeriesPublishedDaysOfWeek.allFilterCases, id: \.self) { dayOfWeek in
Button {
viewModel.selectDayOfWeek(dayOfWeek)
} label: {
Text(dayOfWeek.shortTitle)
.appFont(size: 14, weight: .medium)
.foregroundColor(viewModel.selectedDayOfWeek == dayOfWeek ? Color.black : Color.white)
.lineLimit(1)
.frame(minWidth: 20, minHeight: 20)
.padding(SodaSpacing.s8)
.background(viewModel.selectedDayOfWeek == dayOfWeek ? Color.white : Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s8, style: .continuous))
}
.buttonStyle(.plain)
}
}
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s8, style: .continuous))
.padding(.vertical, SodaSpacing.s8)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(Color.black)
}
private var sortBar: some View {
CreatorChannelSortBar(
selectedSort: viewModel.selectedSort,
totalCount: nil,
onTapSort: {
isSortPopupPresented.toggle()
}
)
}
@ViewBuilder
private var gridContent: some View {
if viewModel.selectedType.usesAudioItems {
LazyVGrid(columns: columns, alignment: .leading, spacing: SodaSpacing.s20) {
ForEach(viewModel.audios) { audio in
Button {
onTapContent(audio.audioContentId)
} label: {
GeometryReader { proxy in
AudioContentThumbnailCard(
item: AudioContentThumbnailCardItem(mainContentAudio: audio),
width: proxy.size.width,
titleTypography: .body1,
subtitleTypography: .caption2,
labelHorizontalPadding: SodaSpacing.s4
)
}
.aspectRatio(0.78, contentMode: .fit)
}
.buttonStyle(.plain)
.onAppear {
viewModel.fetchNextPageIfNeeded(currentAudio: audio)
}
}
}
} else {
LazyVGrid(columns: columns, alignment: .leading, spacing: SodaSpacing.s20) {
ForEach(viewModel.series) { series in
Button {
onTapSeries(series.seriesId)
} label: {
GeometryReader { proxy in
MainContentSeriesThumbnailCard(
item: series,
width: proxy.size.width
)
}
.aspectRatio(0.55, contentMode: .fit)
}
.buttonStyle(.plain)
.onAppear {
viewModel.fetchNextPageIfNeeded(currentSeries: series)
}
}
}
}
}
}
struct MainContentAllView_Previews: PreviewProvider {
static var previews: some View {
MainContentAllView(
viewModel: MainContentAllViewModel(),
onTapContent: { _ in },
onTapSeries: { _ in }
)
}
}

View File

@@ -0,0 +1,233 @@
import Foundation
import Combine
final class MainContentAllViewModel: ObservableObject {
private let repository = MainContentAllRepository()
private var subscription = Set<AnyCancellable>()
private let pageSize = 20
private var latestRequestId = 0
@Published var selectedType: MainContentAllType = .audio
@Published var selectedSort: ContentSort = .latest
@Published var selectedDayOfWeek: SeriesPublishedDaysOfWeek = .currentDeviceDay
@Published var response: MainContentAllTabResponse?
@Published var audios = [MainContentAudioResponse]()
@Published var series = [MainContentSeriesResponse]()
@Published var page = 0
@Published var size = 20
@Published var hasNext = false
@Published var hasLoaded = false
@Published var isLoading = false
@Published var isLoadingNextPage = false
@Published var errorMessage = ""
@Published var isShowPopup = false
var allowedSorts: [ContentSort] {
selectedType == .free ? [.latest, .popular] : ContentSort.allCases
}
var shouldShowEmptyState: Bool {
guard hasLoaded, isLoading == false else { return false }
return selectedType.usesAudioItems ? audios.isEmpty : series.isEmpty
}
func fetchFirstPageIfNeeded() {
guard hasLoaded == false else { return }
fetchFirstPage()
}
func fetchFirstPage() {
fetchContents(page: 0, isNextPage: false)
}
func selectType(_ type: MainContentAllType) {
guard selectedType != type else { return }
selectedType = type
if allowedSorts.contains(selectedSort) == false {
selectedSort = .latest
}
clearItems()
fetchFirstPage()
}
func selectSort(_ sort: ContentSort) {
guard allowedSorts.contains(sort), selectedSort != sort else { return }
selectedSort = sort
clearItems()
fetchFirstPage()
}
func selectDayOfWeek(_ dayOfWeek: SeriesPublishedDaysOfWeek) {
guard selectedType == .series, selectedDayOfWeek != dayOfWeek else { return }
selectedDayOfWeek = dayOfWeek
clearItems()
fetchFirstPage()
}
func fetchNextPageIfNeeded(currentAudio: MainContentAudioResponse) {
guard audios.last?.audioContentId == currentAudio.audioContentId else { return }
fetchNextPageIfPossible()
}
func fetchNextPageIfNeeded(currentSeries: MainContentSeriesResponse) {
guard series.last?.seriesId == currentSeries.seriesId else { return }
fetchNextPageIfPossible()
}
private func fetchNextPageIfPossible() {
guard hasNext, isLoading == false, isLoadingNextPage == false else { return }
fetchContents(page: page + 1, isNextPage: true)
}
private func fetchContents(page requestPage: Int, isNextPage: Bool) {
latestRequestId += 1
let requestId = latestRequestId
if isNextPage {
isLoadingNextPage = true
} else {
isLoading = true
}
repository.getContents(
type: selectedType,
sort: selectedSort,
page: requestPage,
size: pageSize,
dayOfWeek: selectedDayOfWeek
)
.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 }
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<MainContentAllTabResponse>.self, from: response.data)
if let data = decoded.data, decoded.success {
self.applySuccessState(data, isNextPage: isNextPage)
} else {
self.errorMessage = decoded.message ?? I18n.MainContentAll.loadFailedMessage
self.isShowPopup = true
}
} catch {
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.MainContentAll.loadFailedMessage
self.isShowPopup = true
}
self.hasLoaded = true
self.isLoading = false
self.isLoadingNextPage = false
}
.store(in: &subscription)
}
private func applySuccessState(_ data: MainContentAllTabResponse, isNextPage: Bool) {
response = data
selectedType = data.type
selectedSort = data.sort
if let dayOfWeek = data.dayOfWeek {
selectedDayOfWeek = dayOfWeek
}
page = data.page
size = data.size
hasNext = data.hasNext
if data.type.usesAudioItems {
if isNextPage {
audios.append(contentsOf: data.audios)
} else {
audios = data.audios
series = []
}
} else {
if isNextPage {
series.append(contentsOf: data.series)
} else {
series = data.series
audios = []
}
}
}
private func applyFailureState() {
errorMessage = I18n.MainContentAll.loadFailedMessage
isShowPopup = true
hasLoaded = true
isLoading = false
isLoadingNextPage = false
}
private func clearItems() {
audios = []
series = []
response = nil
page = 0
hasNext = false
}
}
extension SeriesPublishedDaysOfWeek {
static var currentDeviceDay: SeriesPublishedDaysOfWeek {
switch Calendar.current.component(.weekday, from: Date()) {
case 1:
return .SUN
case 2:
return .MON
case 3:
return .TUE
case 4:
return .WED
case 5:
return .THU
case 6:
return .FRI
case 7:
return .SAT
default:
return .SUN
}
}
static var allFilterCases: [SeriesPublishedDaysOfWeek] {
[.MON, .TUE, .WED, .THU, .FRI, .SAT, .SUN, .RANDOM]
}
var shortTitle: String {
switch self {
case .SUN:
return I18n.MainContentAll.DayOfWeek.sun
case .MON:
return I18n.MainContentAll.DayOfWeek.mon
case .TUE:
return I18n.MainContentAll.DayOfWeek.tue
case .WED:
return I18n.MainContentAll.DayOfWeek.wed
case .THU:
return I18n.MainContentAll.DayOfWeek.thu
case .FRI:
return I18n.MainContentAll.DayOfWeek.fri
case .SAT:
return I18n.MainContentAll.DayOfWeek.sat
case .RANDOM:
return I18n.MainContentAll.DayOfWeek.random
}
}
}

View File

@@ -0,0 +1,71 @@
import Foundation
enum MainContentAllType: String, Decodable, Encodable, CaseIterable, Hashable {
case audio = "AUDIO"
case series = "SERIES"
case original = "ORIGINAL"
case free = "FREE"
case point = "POINT"
var title: String {
switch self {
case .audio:
return I18n.MainContentAll.TypeChip.audio
case .series:
return I18n.MainContentAll.TypeChip.series
case .original:
return I18n.MainContentAll.TypeChip.original
case .free:
return I18n.MainContentAll.TypeChip.free
case .point:
return I18n.MainContentAll.TypeChip.point
}
}
var usesAudioItems: Bool {
switch self {
case .audio, .free, .point:
return true
case .series, .original:
return false
}
}
}
struct MainContentAllTabResponse: Decodable {
let type: MainContentAllType
let totalCount: Int
let audios: [MainContentAudioResponse]
let series: [MainContentSeriesResponse]
let sort: ContentSort
let dayOfWeek: SeriesPublishedDaysOfWeek?
let page: Int
let size: Int
let hasNext: Bool
}
struct MainContentAudioResponse: Decodable, Identifiable {
let audioContentId: Int
let title: String
let imageUrl: String?
let price: Int
let isAdult: Bool
let isPointAvailable: Bool
let isFirstContent: Bool
let isOriginalSeries: Bool
let creatorNickname: String
var id: Int { audioContentId }
}
struct MainContentSeriesResponse: Decodable, Identifiable {
let seriesId: Int
let title: String
let coverImageUrl: String?
let creatorNickname: String
let isOriginal: Bool
let isAdult: Bool
var id: Int { seriesId }
}

View File

@@ -0,0 +1,55 @@
import Foundation
import Moya
enum MainContentAllApi {
case getContents(
type: MainContentAllType,
sort: ContentSort,
page: Int,
size: Int,
dayOfWeek: SeriesPublishedDaysOfWeek
)
}
extension MainContentAllApi: TargetType {
var baseURL: URL {
return URL(string: BASE_URL)!
}
var path: String {
switch self {
case .getContents:
return "/api/v2/audio/contents"
}
}
var method: Moya.Method {
switch self {
case .getContents:
return .get
}
}
var task: Moya.Task {
switch self {
case .getContents(let type, let sort, let page, let size, let dayOfWeek):
var parameters: [String: Any] = [
"type": type.rawValue,
"sort": sort.rawValue,
"page": page,
"size": size
]
if type == .series {
parameters["dayOfWeek"] = dayOfWeek.rawValue
}
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 MainContentAllRepository {
private let api = MoyaProvider<MainContentAllApi>()
func getContents(
type: MainContentAllType,
sort: ContentSort,
page: Int,
size: Int,
dayOfWeek: SeriesPublishedDaysOfWeek
) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getContents(
type: type,
sort: sort,
page: page,
size: size,
dayOfWeek: dayOfWeek
)
)
}
}

View File

@@ -2,11 +2,17 @@ import Foundation
enum MainContentTab: CaseIterable, Hashable {
case recommendation
case ranking
case all
var title: String {
switch self {
case .recommendation:
return I18n.MainContentRecommendation.tabTitle
case .ranking:
return I18n.MainContentRanking.tabTitle
case .all:
return I18n.MainContentAll.tabTitle
}
}
}

View File

@@ -9,6 +9,7 @@ struct MainContentView: View {
let onTapSeries: (Int) -> Void
@State private var selectedTab: MainContentTab = .recommendation
@StateObject private var allViewModel = MainContentAllViewModel()
var body: some View {
VStack(spacing: 0) {
@@ -46,6 +47,14 @@ struct MainContentView: View {
onTapBanner: onTapBanner,
onTapSeries: onTapSeries
)
case .ranking:
MainPlaceholderTabView(title: MainContentTab.ranking.title)
case .all:
MainContentAllView(
viewModel: allViewModel,
onTapContent: onTapContent,
onTapSeries: onTapSeries
)
}
}
}