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,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)
}
}