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

This commit is contained in:
Yu Sung
2026-07-06 14:03:09 +09:00
parent f3cbf42328
commit b42ca61ce4
28 changed files with 1841 additions and 460 deletions

View File

@@ -0,0 +1,12 @@
import Foundation
enum MainContentTab: CaseIterable, Hashable {
case recommendation
var title: String {
switch self {
case .recommendation:
return I18n.MainContentRecommendation.tabTitle
}
}
}

View File

@@ -0,0 +1,64 @@
import SwiftUI
struct MainContentView: View {
let onTapCanCharge: () -> Void
let onTapSearch: () -> Void
let onTapNotificationList: () -> Void
let onTapContent: (Int) -> Void
let onTapBanner: (AudioBannerResponse) -> Void
let onTapSeries: (Int) -> Void
@State private var selectedTab: MainContentTab = .recommendation
var body: some View {
VStack(spacing: 0) {
HomeTitleBar {
HStack(spacing: SodaSpacing.s14) {
Image("ic_bar_cash")
.onTapGesture { onTapCanCharge() }
Image("ic_bar_search")
.onTapGesture { onTapSearch() }
Image("ic_bar_bell")
.onTapGesture { onTapNotificationList() }
}
}
TextTabBar(
items: MainContentTab.allCases,
selectedItem: $selectedTab,
title: { $0.title }
)
tabContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Color.black.ignoresSafeArea())
}
@ViewBuilder
private var tabContent: some View {
switch selectedTab {
case .recommendation:
MainContentRecommendationView(
onTapContent: onTapContent,
onTapBanner: onTapBanner,
onTapSeries: onTapSeries
)
}
}
}
struct MainContentView_Previews: PreviewProvider {
static var previews: some View {
MainContentView(
onTapCanCharge: {},
onTapSearch: {},
onTapNotificationList: {},
onTapContent: { _ in },
onTapBanner: { _ in },
onTapSeries: { _ in }
)
}
}

View File

@@ -0,0 +1,35 @@
import SwiftUI
struct MainContentAudioBannerSection: View {
let items: [AudioBannerResponse]
let onTapBanner: (AudioBannerResponse) -> Void
var body: some View {
BannerCarouselSection(
items: items,
id: fallbackId,
imageUrl: { $0.imageUrl },
action: onTapBanner
)
}
private func fallbackId(for item: AudioBannerResponse, index: Int) -> String {
if let eventItem = item.eventItem {
return "content-banner-\(index)-event-\(eventItem.id)"
}
if let creatorId = item.creatorId {
return "content-banner-\(index)-creator-\(creatorId)"
}
if let seriesId = item.seriesId {
return "content-banner-\(index)-series-\(seriesId)"
}
if let link = item.link, !link.isEmpty {
return "content-banner-\(index)-link-\(link)"
}
return "content-banner-\(index)-image-\(item.imageUrl)"
}
}

View File

@@ -0,0 +1,20 @@
import SwiftUI
struct MainContentAudioEmptyStateView: View {
let message: String
var body: some View {
VStack(spacing: SodaSpacing.s12) {
Image(systemName: "waveform")
.font(.system(size: 32, weight: .semibold))
.foregroundColor(Color.gray600)
Text(message)
.appFont(.body1)
.foregroundColor(Color.gray400)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(SodaSpacing.s20)
}
}

View File

@@ -0,0 +1,32 @@
import SwiftUI
struct MainContentAudioHorizontalCardSection: View {
let title: String
let items: [AudioCardResponse]
let onTapContent: (Int) -> Void
var body: some View {
if !items.isEmpty {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
SectionTitle(title: title)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: SodaSpacing.s14) {
ForEach(items) { item in
Button {
onTapContent(item.audioContentId)
} label: {
AudioContentThumbnailCard(
item: AudioContentThumbnailCardItem(audioCard: item),
size: .medium
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, SodaSpacing.s20)
}
}
}
}
}

View File

@@ -0,0 +1,60 @@
import SwiftUI
struct MainContentAudioListCarouselSection: View {
let title: String
let items: [AudioCardResponse]
let onTapContent: (Int) -> Void
private var chunks: [[AudioCardResponse]] {
stride(from: 0, to: items.count, by: 3).map {
Array(items[$0..<min($0 + 3, items.count)])
}
}
private var rowWidth: CGFloat {
AudioContentListRowStyle.compactWidth()
}
var body: some View {
if !items.isEmpty {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
SectionTitle(title: title)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: SodaSpacing.s14) {
ForEach(Array(chunks.enumerated()), id: \.offset) { _, chunk in
VStack(spacing: SodaSpacing.s12) {
ForEach(chunk) { item in
AudioContentListRow(
item: rowItem(from: item),
style: .compact(width: rowWidth, showsAdultTag: true)
) {
onTapContent(item.audioContentId)
}
}
}
.frame(width: rowWidth)
}
}
.padding(.horizontal, SodaSpacing.s20)
}
}
}
}
private func rowItem(from item: AudioCardResponse) -> AudioContentListRowItem {
AudioContentListRowItem(
id: item.audioContentId,
title: item.title,
subtitle: item.creatorNickname,
imageUrl: item.imageUrl,
price: item.price,
isAdult: item.isAdult,
isPointAvailable: item.isPointAvailable,
isFirstContent: item.isFirstContent,
isOriginalSeries: item.isOriginalSeries,
isOwned: false,
isRented: false
)
}
}

View File

@@ -0,0 +1,92 @@
import SwiftUI
struct MainContentMostCommentedAudioSection: View {
let title: String
let items: [CommentedAudioResponse]
let onTapContent: (Int) -> Void
private let thumbnailSize: CGFloat = 88
private var cardWidth: CGFloat {
UIScreen.main.bounds.width - (SodaSpacing.s14 * 2)
}
var body: some View {
if !items.isEmpty {
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
SectionTitle(title: title)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: SodaSpacing.s4) {
ForEach(items) { item in
card(item)
}
}
.padding(.leading, SodaSpacing.s14)
.padding(.trailing, SodaSpacing.s20)
}
}
}
}
private func card(_ item: CommentedAudioResponse) -> some View {
Button {
onTapContent(item.audioContentId)
} label: {
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
audioSummary(item)
commentPreview(item)
}
.padding(SodaSpacing.s14)
.frame(width: cardWidth, alignment: .leading)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
.buttonStyle(.plain)
}
private func audioSummary(_ item: CommentedAudioResponse) -> some View {
HStack(alignment: .center, spacing: SodaSpacing.s14) {
DownsampledKFImage(
url: URL(string: item.imageUrl),
size: CGSize(width: thumbnailSize, height: thumbnailSize)
)
.background(Color.gray800)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
Text(item.title)
.appFont(size: 16, weight: .bold)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func commentPreview(_ item: CommentedAudioResponse) -> some View {
HStack(alignment: .center, spacing: SodaSpacing.s14) {
DownsampledKFImage(
url: URL(string: item.latestCommentWriterProfileImageUrl),
size: CGSize(width: 28, height: 28)
)
.background(Color.gray700)
.clipShape(Circle())
Text(item.latestComment)
.appFont(size: 14, weight: .regular)
.foregroundColor(.white)
.lineLimit(2)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.down")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white)
.frame(width: 18, height: 18)
}
.padding(SodaSpacing.s12)
.background(Color.gray800)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
}

View File

@@ -0,0 +1,40 @@
import SwiftUI
struct MainContentRecommendedAudioGridSection: View {
let title: String
let items: [AudioCardResponse]
let onTapContent: (Int) -> Void
private let columns = [
GridItem(.flexible(), spacing: SodaSpacing.s14),
GridItem(.flexible(), spacing: SodaSpacing.s14)
]
var body: some View {
if !items.isEmpty {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
SectionTitle(title: title)
LazyVGrid(columns: columns, alignment: .leading, spacing: SodaSpacing.s20) {
ForEach(items) { item in
Button {
onTapContent(item.audioContentId)
} label: {
GeometryReader { proxy in
let width = proxy.size.width
AudioContentThumbnailCard(
item: AudioContentThumbnailCardItem(audioCard: item),
width: width
)
}
.aspectRatio(0.78, contentMode: .fit)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, SodaSpacing.s20)
}
}
}
}

View File

@@ -0,0 +1,56 @@
import SwiftUI
struct MainContentVoiceOnOnlySection: View {
let title: String
let items: [OriginalSeriesResponse]
let onTapSeries: (Int) -> Void
var body: some View {
if !items.isEmpty {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
SectionTitle(title: title)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: SodaSpacing.s14) {
ForEach(items) { item in
Button {
onTapSeries(item.seriesId)
} label: {
ZStack(alignment: .topLeading) {
DownsampledKFImage(
url: URL(string: item.coverImageUrl),
size: CGSize(width: 122, height: 172)
)
.background(Color.gray800)
originalTag
}
.frame(width: 122, height: 172)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, SodaSpacing.s20)
}
}
}
}
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)
}
}

View File

@@ -0,0 +1,87 @@
import SwiftUI
struct MainContentRecommendationView: View {
let onTapContent: (Int) -> Void
let onTapBanner: (AudioBannerResponse) -> Void
let onTapSeries: (Int) -> Void
@StateObject private var viewModel = MainContentRecommendationViewModel()
var body: some View {
ZStack {
Color.black
.ignoresSafeArea()
if viewModel.isLoading && viewModel.recommendations == nil {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else if viewModel.shouldShowEmptyState {
MainContentAudioEmptyStateView(message: viewModel.emptyStateMessage)
} else {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: SodaSpacing.s24) {
if let recommendations = viewModel.recommendations {
MainContentAudioBannerSection(
items: recommendations.banners,
onTapBanner: onTapBanner
)
MainContentAudioHorizontalCardSection(
title: I18n.MainContentRecommendation.latestAudioSectionTitle,
items: recommendations.latestAudios,
onTapContent: onTapContent
)
MainContentAudioListCarouselSection(
title: I18n.MainContentRecommendation.newAndHotSectionTitle,
items: recommendations.newAndHotAudios,
onTapContent: onTapContent
)
MainContentVoiceOnOnlySection(
title: I18n.MainContentRecommendation.voiceOnOnlySectionTitle,
items: recommendations.originalSeries,
onTapSeries: onTapSeries
)
MainContentAudioHorizontalCardSection(
title: I18n.MainContentRecommendation.freeAudioSectionTitle,
items: recommendations.freeAudios,
onTapContent: onTapContent
)
MainContentAudioHorizontalCardSection(
title: I18n.MainContentRecommendation.pointAudioSectionTitle,
items: recommendations.pointAudios,
onTapContent: onTapContent
)
MainContentMostCommentedAudioSection(
title: I18n.MainContentRecommendation.mostCommentedAudioSectionTitle,
items: recommendations.mostCommentedAudios,
onTapContent: onTapContent
)
MainContentRecommendedAudioGridSection(
title: I18n.MainContentRecommendation.recommendedAudioSectionTitle,
items: recommendations.recommendedAudios,
onTapContent: onTapContent
)
}
}
.padding(.vertical, SodaSpacing.s20)
}
}
}
.onAppear {
if !viewModel.hasLoaded {
viewModel.fetchRecommendations()
}
}
.sodaToast(
isPresented: $viewModel.isShowPopup,
message: viewModel.errorMessage,
autohideIn: 2
)
}
}

View File

@@ -0,0 +1,72 @@
import Foundation
import Combine
final class MainContentRecommendationViewModel: ObservableObject {
private let repository = MainContentRecommendationRepository()
private var subscription = Set<AnyCancellable>()
@Published var isLoading = false
@Published var isShowPopup = false
@Published var errorMessage = ""
@Published var recommendations: AudioRecommendationsResponse?
@Published var hasLoaded = false
var emptyStateMessage: String {
if !errorMessage.isEmpty {
return errorMessage
}
return I18n.MainContentRecommendation.emptyStateMessage
}
var shouldShowEmptyState: Bool {
hasLoaded && !isLoading && (recommendations?.isEmpty ?? true)
}
func fetchRecommendations() {
guard !isLoading else { return }
isLoading = true
repository.getAudioRecommendations()
.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.errorMessage = I18n.MainContentRecommendation.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<AudioRecommendationsResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.errorMessage = ""
self.recommendations = data
} else {
self.errorMessage = decoded.message ?? I18n.MainContentRecommendation.loadFailedMessage
self.isShowPopup = true
}
} catch {
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.MainContentRecommendation.loadFailedMessage
self.isShowPopup = true
}
self.isLoading = false
self.hasLoaded = true
}
.store(in: &subscription)
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
struct AudioRecommendationsResponse: Decodable {
let banners: [AudioBannerResponse]
let originalSeries: [OriginalSeriesResponse]
let latestAudios: [AudioCardResponse]
let newAndHotAudios: [AudioCardResponse]
let freeAudios: [AudioCardResponse]
let pointAudios: [AudioCardResponse]
let mostCommentedAudios: [CommentedAudioResponse]
let recommendedAudios: [AudioCardResponse]
var isEmpty: Bool {
banners.isEmpty &&
originalSeries.isEmpty &&
latestAudios.isEmpty &&
newAndHotAudios.isEmpty &&
freeAudios.isEmpty &&
pointAudios.isEmpty &&
mostCommentedAudios.isEmpty &&
recommendedAudios.isEmpty
}
}
struct AudioBannerResponse: Decodable, Hashable, Identifiable {
var id: String { "\(imageUrl)-\(creatorId ?? 0)-\(seriesId ?? 0)-\(link ?? "")" }
let imageUrl: String
let eventItem: EventItem?
let creatorId: Int?
let seriesId: Int?
let link: String?
}
struct OriginalSeriesResponse: Decodable, Hashable, Identifiable {
var id: Int { seriesId }
let seriesId: Int
let coverImageUrl: String
}
struct AudioCardResponse: Decodable, Hashable, Identifiable {
var id: Int { audioContentId }
let audioContentId: Int
let title: String
let duration: String
let imageUrl: String
let price: Int
let isAdult: Bool
let isPointAvailable: Bool
let isFirstContent: Bool
let isOriginalSeries: Bool
let creatorNickname: String
}
struct CommentedAudioResponse: Decodable, Hashable, Identifiable {
var id: Int { audioContentId }
let audioContentId: Int
let title: String
let imageUrl: String
let latestComment: String
let latestCommentWriterProfileImageUrl: String
}

View File

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

View File

@@ -0,0 +1,12 @@
import Foundation
import CombineMoya
import Combine
import Moya
final class MainContentRecommendationRepository {
private let api = MoyaProvider<MainContentRecommendationApi>()
func getAudioRecommendations() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getAudioRecommendations)
}
}

View File

@@ -13,27 +13,12 @@ struct MainHomeBannerSection: View {
}
var body: some View {
if !items.isEmpty {
BannerCarousel(items: carouselItems) { item in
guard let banner = banner(for: item) else { return }
onTapBanner(banner)
}
}
}
private var carouselItems: [BannerCarouselItem] {
items.enumerated().map { index, item in
BannerCarouselItem(
id: fallbackId(for: item, index: index),
imageUrl: item.imageUrl
)
}
}
private func banner(for carouselItem: BannerCarouselItem) -> RecommendationBannerResponse? {
items.enumerated().first { index, item in
fallbackId(for: item, index: index) == carouselItem.id
}?.element
BannerCarouselSection(
items: items,
id: fallbackId,
imageUrl: { $0.imageUrl },
action: onTapBanner
)
}
private func fallbackId(for item: RecommendationBannerResponse, index: Int) -> String {

View File

@@ -172,7 +172,14 @@ struct MainView: View {
onTapFollowingSchedule: handleFollowingScheduleTap
)
case .content:
MainPlaceholderTabView(title: MainTab.content.title)
MainContentView(
onTapCanCharge: handleHomeCanChargeTap,
onTapSearch: handleHomeSearchTap,
onTapNotificationList: handleHomeNotificationListTap,
onTapContent: handleRecommendationContentTap,
onTapBanner: handleContentAudioBannerTap,
onTapSeries: handleContentOriginalSeriesTap
)
case .chat:
MainPlaceholderTabView(title: MainTab.chat.title)
case .my:
@@ -548,10 +555,45 @@ struct MainView: View {
openRecommendationBannerLink(item.link)
}
private func handleContentAudioBannerTap(_ item: AudioBannerResponse) {
if let eventItem = item.eventItem {
performRecommendationDetailAction {
appState.setAppStep(step: .eventDetail(event: eventItem))
}
return
}
if let creatorId = item.creatorId {
handleRecommendationCreatorTap(creatorId: creatorId)
return
}
if let seriesId = item.seriesId {
handleContentOriginalSeriesTap(seriesId: seriesId)
return
}
openRecommendationBannerLink(item.link)
}
private func handleContentOriginalSeriesTap(seriesId: Int) {
guard seriesId > 0 else { return }
performRecommendationDetailAction {
appState.setAppStep(step: .seriesDetail(seriesId: seriesId))
}
}
private func openRecommendationBannerLink(_ link: String?) {
guard let link = link?.trimmingCharacters(in: .whitespacesAndNewlines),
!link.isEmpty,
let url = URL(string: link),
!link.isEmpty else {
return
}
if AppDeepLinkHandler.handle(urlString: link) {
return
}
guard let url = URL(string: link),
UIApplication.shared.canOpenURL(url) else {
return
}