feat(content): 추천 전체보기를 연결한다

This commit is contained in:
Yu Sung
2026-07-07 11:54:31 +09:00
parent b33aed94c6
commit 981c02a8e1
18 changed files with 1169 additions and 294 deletions

View File

@@ -23,7 +23,7 @@ final class MainContentAllViewModel: ObservableObject {
@Published var isShowPopup = false
var allowedSorts: [ContentSort] {
selectedType == .free ? [.latest, .popular] : ContentSort.allCases
allowedSorts(for: selectedType)
}
var shouldShowEmptyState: Bool {
@@ -40,6 +40,19 @@ final class MainContentAllViewModel: ObservableObject {
fetchContents(page: 0, isNextPage: false)
}
func apply(type: MainContentAllType, sort: ContentSort) {
guard allowedSorts(for: type).contains(sort) else { return }
if selectedType == type, selectedSort == sort, hasLoaded {
return
}
selectedType = type
selectedSort = sort
clearItems()
fetchFirstPage()
}
func selectType(_ type: MainContentAllType) {
guard selectedType != type else { return }
@@ -77,6 +90,10 @@ final class MainContentAllViewModel: ObservableObject {
fetchNextPageIfPossible()
}
private func allowedSorts(for type: MainContentAllType) -> [ContentSort] {
type == .free ? [.latest, .popular] : ContentSort.allCases
}
private func fetchNextPageIfPossible() {
guard hasNext, isLoading == false, isLoadingNextPage == false else { return }
fetchContents(page: page + 1, isNextPage: true)

View File

@@ -7,6 +7,7 @@ struct MainContentView: View {
let onTapContent: (Int) -> Void
let onTapBanner: (AudioBannerResponse) -> Void
let onTapSeries: (Int) -> Void
let onTapNewAndHotAll: () -> Void
@State private var selectedTab: MainContentTab = .recommendation
@StateObject private var allViewModel = MainContentAllViewModel()
@@ -45,7 +46,12 @@ struct MainContentView: View {
MainContentRecommendationView(
onTapContent: onTapContent,
onTapBanner: onTapBanner,
onTapSeries: onTapSeries
onTapSeries: onTapSeries,
onTapLatestAudioAll: { applyAllTab(type: .audio, sort: .latest) },
onTapNewAndHotAll: onTapNewAndHotAll,
onTapVoiceOnOnlyAll: { applyAllTab(type: .original, sort: .latest) },
onTapFreeAudioAll: { applyAllTab(type: .free, sort: .popular) },
onTapPointAudioAll: { applyAllTab(type: .point, sort: .popular) }
)
case .ranking:
MainContentRankingView(onTapContent: onTapContent)
@@ -57,6 +63,11 @@ struct MainContentView: View {
)
}
}
private func applyAllTab(type: MainContentAllType, sort: ContentSort) {
selectedTab = .all
allViewModel.apply(type: type, sort: sort)
}
}
struct MainContentView_Previews: PreviewProvider {
@@ -67,7 +78,8 @@ struct MainContentView_Previews: PreviewProvider {
onTapNotificationList: {},
onTapContent: { _ in },
onTapBanner: { _ in },
onTapSeries: { _ in }
onTapSeries: { _ in },
onTapNewAndHotAll: {}
)
}
}

View File

@@ -0,0 +1,108 @@
import SwiftUI
struct ContentOverviewView: View {
@StateObject private var viewModel: ContentOverviewViewModel
let onTapContent: (Int) -> Void
private let columns = [
GridItem(.flexible(), spacing: SodaSpacing.s4),
GridItem(.flexible(), spacing: SodaSpacing.s4)
]
init(
type: ContentOverviewType = .newAndHotAudio,
onTapContent: @escaping (Int) -> Void
) {
self._viewModel = StateObject(wrappedValue: ContentOverviewViewModel(type: type))
self.onTapContent = onTapContent
}
var body: some View {
VStack(spacing: 0) {
titleBar
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Color.black.ignoresSafeArea())
.onAppear {
viewModel.fetchFirstPageIfNeeded()
}
.sodaToast(
isPresented: $viewModel.isShowPopup,
message: viewModel.errorMessage,
autohideIn: 2
)
}
private var titleBar: some View {
TitleBar {
HStack(spacing: SodaSpacing.s14) {
Button {
AppState.shared.back()
} label: {
Image("ic_new_bar_back")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
Text(I18n.MainContentRecommendation.newAndHotSectionTitle)
.appFont(.heading2)
.foregroundColor(.white)
.lineLimit(1)
}
} trailing: {
EmptyView()
}
}
@ViewBuilder
private var content: some View {
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) {
LazyVGrid(columns: columns, alignment: .leading, spacing: SodaSpacing.s20) {
ForEach(viewModel.items) { item in
Button {
onTapContent(item.contentId)
} label: {
GeometryReader { proxy in
AudioContentThumbnailCard(
item: AudioContentThumbnailCardItem(contentOverviewItem: item),
width: proxy.size.width
)
}
.aspectRatio(0.78, contentMode: .fit)
}
.buttonStyle(.plain)
.onAppear {
viewModel.fetchNextPageIfNeeded(currentItem: item)
}
}
}
.padding(.horizontal, SodaSpacing.s14)
.padding(.top, SodaSpacing.s8)
.padding(.bottom, SodaSpacing.s20)
if viewModel.isLoadingNextPage {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.vertical, SodaSpacing.s20)
}
}
}
}
}
struct ContentOverviewView_Previews: PreviewProvider {
static var previews: some View {
ContentOverviewView(onTapContent: { _ in })
}
}

View File

@@ -0,0 +1,118 @@
import Foundation
import Combine
final class ContentOverviewViewModel: ObservableObject {
private let repository = ContentOverviewRepository()
private var subscription = Set<AnyCancellable>()
private let pageSize = 20
private var latestRequestId = 0
let type: ContentOverviewType
@Published var items = [ContentOverviewItemResponse]()
@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 shouldShowEmptyState: Bool {
hasLoaded && !isLoading && items.isEmpty
}
init(type: ContentOverviewType = .newAndHotAudio) {
self.type = type
}
func fetchFirstPageIfNeeded() {
guard !hasLoaded else { return }
fetchFirstPage()
}
func fetchFirstPage() {
fetchContents(page: 0, isNextPage: false)
}
func fetchNextPageIfNeeded(currentItem: ContentOverviewItemResponse) {
guard items.last?.contentId == currentItem.contentId 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: type, page: requestPage, size: pageSize)
.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<ContentOverviewPageResponse>.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: ContentOverviewPageResponse, isNextPage: Bool) {
page = data.page
size = data.size
hasNext = data.hasNext
errorMessage = ""
if isNextPage {
items.append(contentsOf: data.items)
} else {
items = data.items
}
}
private func applyFailureState() {
errorMessage = I18n.MainContentAll.loadFailedMessage
isShowPopup = true
hasLoaded = true
isLoading = false
isLoadingNextPage = false
}
}

View File

@@ -0,0 +1,27 @@
import Foundation
enum ContentOverviewType: String, Decodable, Encodable, Hashable {
case newAndHotAudio = "NEW_AND_HOT_AUDIO"
}
struct ContentOverviewPageResponse: Decodable {
let type: ContentOverviewType
let items: [ContentOverviewItemResponse]
let page: Int
let size: Int
let hasNext: Bool
}
struct ContentOverviewItemResponse: Decodable, Identifiable {
let contentId: Int
let title: String
let coverImage: String?
let price: Int
let isPointAvailable: Bool
let creatorNickname: String
let isAdult: Bool
let isFirstContent: Bool
let isOriginalSeries: Bool
var id: Int { contentId }
}

View File

@@ -0,0 +1,44 @@
import Foundation
import Moya
enum ContentOverviewApi {
case getContents(type: ContentOverviewType, page: Int, size: Int)
}
extension ContentOverviewApi: TargetType {
var baseURL: URL {
return URL(string: BASE_URL)!
}
var path: String {
switch self {
case .getContents:
return "/api/v2/contents"
}
}
var method: Moya.Method {
switch self {
case .getContents:
return .get
}
}
var task: Moya.Task {
switch self {
case .getContents(let type, let page, let size):
return .requestParameters(
parameters: [
"page": page,
"size": size,
"type": type.rawValue
],
encoding: URLEncoding.queryString
)
}
}
var headers: [String: String]? {
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
}
}

View File

@@ -0,0 +1,16 @@
import Foundation
import Combine
import CombineMoya
import Moya
final class ContentOverviewRepository {
private let api = MoyaProvider<ContentOverviewApi>()
func getContents(
type: ContentOverviewType,
page: Int,
size: Int
) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getContents(type: type, page: page, size: size))
}
}

View File

@@ -3,12 +3,13 @@ import SwiftUI
struct MainContentAudioHorizontalCardSection: View {
let title: String
let items: [AudioCardResponse]
let action: (() -> Void)?
let onTapContent: (Int) -> Void
var body: some View {
if !items.isEmpty {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
SectionTitle(title: title)
SectionTitle(title: title, action: action)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: SodaSpacing.s14) {

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct MainContentAudioListCarouselSection: View {
let title: String
let items: [AudioCardResponse]
let action: (() -> Void)?
let onTapContent: (Int) -> Void
private var chunks: [[AudioCardResponse]] {
@@ -18,7 +19,7 @@ struct MainContentAudioListCarouselSection: View {
var body: some View {
if !items.isEmpty {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
SectionTitle(title: title)
SectionTitle(title: title, action: action)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: SodaSpacing.s14) {

View File

@@ -3,12 +3,13 @@ import SwiftUI
struct MainContentVoiceOnOnlySection: View {
let title: String
let items: [OriginalSeriesResponse]
let action: (() -> Void)?
let onTapSeries: (Int) -> Void
var body: some View {
if !items.isEmpty {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
SectionTitle(title: title)
SectionTitle(title: title, action: action)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: SodaSpacing.s14) {

View File

@@ -4,6 +4,11 @@ struct MainContentRecommendationView: View {
let onTapContent: (Int) -> Void
let onTapBanner: (AudioBannerResponse) -> Void
let onTapSeries: (Int) -> Void
let onTapLatestAudioAll: () -> Void
let onTapNewAndHotAll: () -> Void
let onTapVoiceOnOnlyAll: () -> Void
let onTapFreeAudioAll: () -> Void
let onTapPointAudioAll: () -> Void
@StateObject private var viewModel = MainContentRecommendationViewModel()
@@ -29,30 +34,35 @@ struct MainContentRecommendationView: View {
MainContentAudioHorizontalCardSection(
title: I18n.MainContentRecommendation.latestAudioSectionTitle,
items: recommendations.latestAudios,
action: onTapLatestAudioAll,
onTapContent: onTapContent
)
MainContentAudioListCarouselSection(
title: I18n.MainContentRecommendation.newAndHotSectionTitle,
items: recommendations.newAndHotAudios,
action: onTapNewAndHotAll,
onTapContent: onTapContent
)
MainContentVoiceOnOnlySection(
title: I18n.MainContentRecommendation.voiceOnOnlySectionTitle,
items: recommendations.originalSeries,
action: onTapVoiceOnOnlyAll,
onTapSeries: onTapSeries
)
MainContentAudioHorizontalCardSection(
title: I18n.MainContentRecommendation.freeAudioSectionTitle,
items: recommendations.freeAudios,
action: onTapFreeAudioAll,
onTapContent: onTapContent
)
MainContentAudioHorizontalCardSection(
title: I18n.MainContentRecommendation.pointAudioSectionTitle,
items: recommendations.pointAudios,
action: onTapPointAudioAll,
onTapContent: onTapContent
)