콘텐츠 메인

- 시리즈 탭 UI 페이지 생성
This commit is contained in:
Yu Sung 2025-02-21 21:10:16 +09:00
parent cf5d0dc19e
commit 6bd27c5301
19 changed files with 1439 additions and 22 deletions

View File

@ -141,4 +141,6 @@ enum AppStep {
case auditionRoleDetail(roleId: Int, auditionTitle: String)
case searchChannel
case contentMain(startTab: ContentMainTab)
}

View File

@ -38,8 +38,13 @@ enum ContentApi {
case unpinContent(contentId: Int)
case getAudioContentByTheme(themeId: Int, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, sort: ContentAllByThemeViewModel.Sort)
case generateUrl(contentId: Int)
case getContentMainHome
case getPopularContentByCreator(creatorId: Int)
case getContentMainSeries
case getRecommendSeriesListByGenre(genreId: Int)
case getRecommendSeriesByCreator(creatorId: Int)
}
extension ContentApi: TargetType {
@ -141,6 +146,15 @@ extension ContentApi: TargetType {
case .getPopularContentByCreator:
return "/v2/audio-content/main/home/popular-content-by-creator"
case .getContentMainSeries:
return "/v2/audio-content/main/series"
case .getRecommendSeriesListByGenre:
return "/v2/audio-content/main/series/recommend-by-genre"
case .getRecommendSeriesByCreator:
return "/v2/audio-content/main/series/recommend-series-by-creator"
}
}
@ -155,7 +169,7 @@ extension ContentApi: TargetType {
case .getMainBannerList, .getMainOrderList, .getNewContentUploadCreatorList, .getCurationList, .getAudioContentByTheme:
return .get
case .getContentMainHome, .getPopularContentByCreator:
case .getContentMainHome, .getPopularContentByCreator, .getContentMainSeries, .getRecommendSeriesListByGenre, .getRecommendSeriesByCreator:
return .get
case .likeContent, .modifyAudioContent, .modifyComment, .unpinContent:
@ -312,12 +326,20 @@ extension ContentApi: TargetType {
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentMainHome:
case .getContentMainHome, .getContentMainSeries:
return .requestPlain
case .getRecommendSeriesListByGenre(let genreId):
let parameters = ["genreId": genreId]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getPopularContentByCreator(let creatorId):
let parameters = ["creatorId": creatorId]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getRecommendSeriesByCreator(let creatorId):
let parameters = ["creatorId": creatorId]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
}
}

View File

@ -0,0 +1,34 @@
//
// ContentMainNoItemView.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
struct ContentMainNoItemView: View {
var body: some View {
VStack(spacing: 0) {
Image("ic_no_item")
.resizable()
.frame(width: 60, height: 60)
Text("마이페이지에서 본인인증을 해주세요")
.font(.custom(Font.medium.rawValue, size: 13))
.foregroundColor(.graybb)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.center)
.lineSpacing(8)
.padding(.vertical, 8)
}
.padding(.vertical, 16.7)
.frame(maxWidth: .infinity)
.background(Color.bg)
.cornerRadius(4.7)
}
}
#Preview {
ContentMainNoItemView()
}

View File

@ -7,12 +7,131 @@
import SwiftUI
enum ContentMainTab {
case HOME
case SERIES
case CONTENT
case ALARM
case ASMR
case REPLAY
case FREE
}
struct TabItem {
let title: String
let tab: ContentMainTab
}
struct ContentMainViewV2: View {
@State private var selectedTab: ContentMainTab = .SERIES
let tabItemList = [
TabItem(title: "", tab: .HOME),
TabItem(title: "시리즈", tab: .SERIES),
TabItem(title: "단편", tab: .CONTENT),
TabItem(title: "모닝콜", tab: .ALARM),
TabItem(title: "ASMR", tab: .ASMR),
TabItem(title: "다시듣기", tab: .REPLAY),
TabItem(title: "무료", tab: .FREE)
]
init(selectedTab: ContentMainTab = .SERIES) {
self._selectedTab = State(initialValue: selectedTab)
}
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
HStack(spacing: 0) {
Text("콘텐츠 마켓")
.font(.custom(Font.bold.rawValue, size: 21.3))
.foregroundColor(Color.button)
Spacer()
Image("ic_content_keep")
.onTapGesture {
AppState.shared.setAppStep(step: .myBox(currentTab: .orderlist))
}
}
.padding(.horizontal, 13.3)
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(0..<tabItemList.count, id: \.self) { index in
let tabItem = tabItemList[index]
Text(tabItem.title)
.font(
.custom(
selectedTab == tabItem.tab ?
Font.bold.rawValue :
Font.medium.rawValue,
size: 16
)
)
.foregroundColor(
selectedTab == tabItem.tab ?
.button :
.graybb
)
.padding(.horizontal, 12)
.onTapGesture {
if selectedTab != tabItem.tab {
selectedTab = tabItem.tab
proxy.scrollTo(tabItem.tab, anchor: .center)
}
}
.id(tabItem.tab)
}
}
.padding(.vertical, 15)
.padding(.horizontal, 13.3)
}
.onAppear {
withAnimation {
proxy.scrollTo(selectedTab, anchor: .center)
}
}
.onChange(of: selectedTab) { newTab in
withAnimation {
if newTab == .HOME {
AppState.shared.back()
} else {
proxy.scrollTo(newTab, anchor: .center)
}
}
}
}
ZStack {
switch selectedTab {
case .HOME:
EmptyView()
case .SERIES:
ContentMainTabSeriesView()
case .CONTENT:
EmptyView()
case .ALARM:
EmptyView()
case .ASMR:
EmptyView()
case .REPLAY:
EmptyView()
case .FREE:
EmptyView()
}
}
}
}
}
}
#Preview {
ContentMainViewV2()
ContentMainViewV2(selectedTab: .SERIES)
}

View File

@ -76,28 +76,52 @@ struct ContentMainTabHomeView: View {
ContentMainTabCategoryView(
imageName: "ic_category_series",
title: "시리즈",
onClick: {}
onClick: {
AppState.shared
.setAppStep(
step: .contentMain(
startTab: .SERIES
)
)
}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_content",
title: "단편",
onClick: {}
onClick: {
AppState.shared
.setAppStep(
step: .contentMain(
startTab: .CONTENT
)
)
}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_audio_book",
title: "오디오북",
onClick: {}
onClick: {
viewModel.errorMessage = "준비중입니다."
viewModel.isShowPopup = true
}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_alarm",
title: "모닝콜",
onClick: {}
onClick: {
AppState.shared
.setAppStep(
step: .contentMain(
startTab: .ALARM
)
)
}
)
.frame(maxWidth: .infinity)
}
@ -106,28 +130,52 @@ struct ContentMainTabHomeView: View {
ContentMainTabCategoryView(
imageName: "ic_category_asmr",
title: "ASMR",
onClick: {}
onClick: {
AppState.shared
.setAppStep(
step: .contentMain(
startTab: .ASMR
)
)
}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_replay",
title: "다시듣기",
onClick: {}
onClick: {
AppState.shared
.setAppStep(
step: .contentMain(
startTab: .REPLAY
)
)
}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_audio_toon",
title: "오디오툰",
onClick: {}
onClick: {
viewModel.errorMessage = "준비중입니다."
viewModel.isShowPopup = true
}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_free",
title: "무료",
onClick: {}
onClick: {
AppState.shared
.setAppStep(
step: .contentMain(
startTab: .FREE
)
)
}
)
.frame(maxWidth: .infinity)
}

View File

@ -0,0 +1,83 @@
//
// ContentMainCompletedSeriesView.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
struct ContentMainCompletedSeriesView: View {
let itemList: [SeriesListItem]
let onClickMore: () -> Void
var body: some View {
VStack(spacing: 13.3) {
HStack(spacing: 0) {
Text("완결 시리즈")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(.grayee)
Spacer()
Image("ic_forward")
.onTapGesture {
onClickMore()
}
}
.padding(.horizontal, 13.3)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 13.3) {
ForEach(0..<itemList.count, id: \.self) { index in
let item = itemList[index]
SeriesListBigItemView(
item: item,
isVisibleCreator: true
)
}
}
.padding(.horizontal, 13.3)
}
}
}
}
#Preview {
ContentMainCompletedSeriesView(
itemList: [
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
),
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
)
],
onClickMore: {}
)
}

View File

@ -0,0 +1,115 @@
//
// ContentMainNewOrRecommendSeriesView.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
import Kingfisher
struct ContentMainNewOrRecommendSeriesView: View {
let title: String
let recommendSeriesList: [GetRecommendSeriesListResponse]
var body: some View {
VStack(alignment: .leading, spacing: 13.3) {
Text(title)
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color(hex: "eeeeee"))
.padding(.horizontal, 13.3)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 13.3) {
ForEach(0..<recommendSeriesList.count, id: \.self) { index in
let item = recommendSeriesList[index]
ContentMainNewOrRecommendSeriesItemView(item: item)
.onTapGesture {
AppState.shared
.setAppStep(step: .seriesDetail(seriesId: item.seriesId))
}
}
}
.padding(.horizontal, 13.3)
}
}
}
}
struct ContentMainNewOrRecommendSeriesItemView: View {
let item: GetRecommendSeriesListResponse
var body: some View {
VStack(alignment: .leading, spacing: 9) {
KFImage(URL(string: item.imageUrl))
.cancelOnDisappear(true)
.downsampling(size: CGSize(width: 267, height: 141.3))
.resizable()
.scaledToFill()
.frame(width: 267, height: 141.3)
.clipShape(RoundedRectangle(cornerRadius: 5))
Text(item.title)
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(.grayee)
HStack(spacing: 5.3) {
KFImage(URL(string: item.creatorProfileImageUrl))
.cancelOnDisappear(true)
.downsampling(size: CGSize(width: 30, height: 30))
.resizable()
.scaledToFill()
.frame(width: 30, height: 30)
.clipShape(Circle())
Text(item.creatorNickname)
.font(.custom(Font.medium.rawValue, size: 10))
.foregroundColor(.gray77)
}
.onTapGesture {
AppState.shared
.setAppStep(step: .creatorDetail(userId: item.creatorId))
}
}
}
}
#Preview {
ContentMainNewOrRecommendSeriesView(
title: "추천 무료 시리즈",
recommendSeriesList: [
GetRecommendSeriesListResponse(
seriesId: 1,
title: "시리즈 1",
imageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
creatorId: 1,
creatorNickname: "유저1",
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
),
GetRecommendSeriesListResponse(
seriesId: 1,
title: "시리즈 1",
imageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
creatorId: 1,
creatorNickname: "유저1",
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
)
]
)
}
/*
contentId: 1,
title: "안녕하세요 오늘은 커버곡을 들려드릴께요....안녕하세요 오늘은 커버곡을 들려드릴께요....",
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
themeStr: "커버곡",
price: 100,
duration: "00:30:20",
creatorId: 1,
creatorNickname: "유저1",
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
*/

View File

@ -8,11 +8,109 @@
import SwiftUI
struct ContentMainSeriesByGenreView: View {
let genreList: [GetSeriesGenreListResponse]
let itemList: [SeriesListItem]
let onClickGenre: (Int) -> Void
@State private var selectedGenreId = 0
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
VStack(alignment: .leading, spacing: 13.3) {
Text("장르별 추천 시리즈")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color.grayee)
.padding(.horizontal, 13.3)
ContentMainSeriesGenreView(
genreList: genreList,
selectGenre: {
selectedGenreId = $0
onClickGenre($0)
},
selectedGenreId: $selectedGenreId
)
if !itemList.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 13.3) {
ForEach(0..<itemList.count, id: \.self) { index in
let item = itemList[index]
SeriesListBigItemView(
item: item,
isVisibleCreator: true
)
}
}
.padding(.horizontal, 13.3)
}
} else {
ContentMainNoItemView()
}
}
.onAppear {
if !genreList.isEmpty {
selectedGenreId = genreList[0].id
}
}
}
}
#Preview {
ContentMainSeriesByGenreView()
ContentMainSeriesByGenreView(
genreList: [
GetSeriesGenreListResponse(id: 1, genre: "test"),
GetSeriesGenreListResponse(id: 2, genre: "test2"),
GetSeriesGenreListResponse(id: 3, genre: "test3")
],
itemList: [
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: false,
isPopular: true
),
SeriesListItem(
seriesId: 2,
title: "제목2",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "랜덤",
isComplete: false,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: false
),
SeriesListItem(
seriesId: 2,
title: "제목2",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "랜덤",
isComplete: false,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: false
)
],
onClickGenre: { _ in }
)
}

View File

@ -0,0 +1,158 @@
//
// ContentMainSeriesCurationView.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
struct ContentMainSeriesCurationView: View {
let curationList: [GetSeriesCurationResponse]
var body: some View {
LazyVStack(spacing: 30) {
ForEach(0..<curationList.count, id: \.self) { index in
let curation = curationList[index]
ContentMainSeriesCurationItemView(curation: curation)
}
}
}
}
struct ContentMainSeriesCurationItemView: View {
let curation: GetSeriesCurationResponse
var body: some View {
VStack(alignment: .leading, spacing: 13.3) {
Text(curation.title)
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(.grayee)
.padding(.horizontal, 13.3)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 13.3) {
ForEach(0..<curation.items.count, id: \.self) { index in
let item = curation.items[index]
SeriesListBigItemView(item: item, isVisibleCreator: true)
}
}
.padding(.horizontal, 13.3)
}
}
}
}
#Preview {
ContentMainSeriesCurationView(
curationList: [
GetSeriesCurationResponse(
title: "test",
items: [
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
),
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
)
]
),
GetSeriesCurationResponse(
title: "test2",
items: [
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
),
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
)
]
),
GetSeriesCurationResponse(
title: "test3",
items: [
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
),
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
)
]
)
]
)
}

View File

@ -8,11 +8,51 @@
import SwiftUI
struct ContentMainSeriesGenreView: View {
let genreList: [GetSeriesGenreListResponse]
let selectGenre: (Int) -> Void
@Binding var selectedGenreId: Int
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 8) {
ForEach(0..<genreList.count, id: \.self) { index in
let genre = genreList[index]
Text(genre.genre)
.font(.custom(Font.medium.rawValue, size: 14.7))
.foregroundColor(selectedGenreId == genre.id ? Color.button : Color.gray77)
.padding(.horizontal, 13.3)
.padding(.vertical, 9.3)
.border(
selectedGenreId == genre.id ? Color.button : Color.grayee,
width: 1
)
.cornerRadius(16.7)
.overlay(
RoundedRectangle(cornerRadius: CGFloat(16.7))
.stroke(lineWidth: 1)
.foregroundColor(selectedGenreId == genre.id ? Color.button : Color.grayee)
)
.onTapGesture {
if selectedGenreId != genre.id {
selectGenre(genre.id)
}
}
}
}
.padding(.horizontal, 13.3)
}
}
}
#Preview {
ContentMainSeriesGenreView()
ContentMainSeriesGenreView(
genreList: [
GetSeriesGenreListResponse(id: 1, genre: "test"),
GetSeriesGenreListResponse(id: 2, genre: "test2"),
GetSeriesGenreListResponse(id: 3, genre: "test3")
],
selectGenre: { _ in },
selectedGenreId: .constant(2)
)
}

View File

@ -0,0 +1,141 @@
//
// ContentMainSeriesRankingView.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
import Kingfisher
struct ContentMainSeriesRankingView: View {
let seriesList: [SeriesListItem]
let rows = [
GridItem(.flexible(), alignment: .leading),
GridItem(.flexible(), alignment: .leading),
GridItem(.flexible(), alignment: .leading)
]
var body: some View {
VStack(alignment: .leading, spacing: 13.3) {
Text("일간 랭킹")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(.grayee)
.padding(.horizontal, 13.3)
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: rows, spacing: 13.3) {
ForEach(0..<seriesList.count, id: \.self) { index in
let series = seriesList[index]
HStack(spacing: 0) {
KFImage(URL(string: series.coverImage))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: 60,
height: 85
)
)
.resizable()
.frame(width: 60, height: 85)
.cornerRadius(2.7)
Text("\(index + 1)")
.font(.custom(Font.bold.rawValue, size: 16.7))
.foregroundColor(.button)
.padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 8) {
Text(series.title)
.lineLimit(2)
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(.grayd2)
Text(series.creator.nickname)
.font(.custom(Font.medium.rawValue, size: 11))
.foregroundColor(.gray77)
}
}
.frame(maxWidth: screenSize().width * 0.66, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
AppState
.shared
.setAppStep(step: .seriesDetail(seriesId: series.seriesId))
}
}
}
.padding(.horizontal, 13.3)
}
}
}
}
#Preview {
ContentMainSeriesRankingView(
seriesList: [
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
),
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
),
SeriesListItem(
seriesId: 3,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
),
SeriesListItem(
seriesId: 4,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
)
]
)
}

View File

@ -6,3 +6,22 @@
//
import Foundation
import CombineMoya
import Combine
import Moya
final class ContentMainTabSeriesRepository {
private let api = MoyaProvider<ContentApi>()
func getContentMainSeries() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getContentMainSeries)
}
func getRecommendSeriesListByGenre(genreId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getRecommendSeriesListByGenre(genreId: genreId))
}
func getRecommendSeriesByCreator(creatorId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getRecommendSeriesByCreator(creatorId: creatorId))
}
}

View File

@ -8,8 +8,86 @@
import SwiftUI
struct ContentMainTabSeriesView: View {
@StateObject var viewModel = ContentMainTabSeriesViewModel()
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
BaseView(isLoading: $viewModel.isLoading) {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
if !viewModel.bannerList.isEmpty {
ContentMainBannerViewV2(bannerList: viewModel.bannerList)
.padding(.horizontal, 13.3)
}
if !viewModel.originalAudioDramaList.isEmpty {
ContentMainOriginalAudioDramaView(itemList: viewModel.originalAudioDramaList) {
}
.padding(.top, 30)
}
if !viewModel.rankSeriesList.isEmpty {
ContentMainSeriesRankingView(seriesList: viewModel.rankSeriesList)
.padding(.top, 30)
}
if !viewModel.genreList.isEmpty {
ContentMainSeriesByGenreView(
genreList: viewModel.genreList,
itemList: viewModel.recommendSeriesList
) {
viewModel.getRecommendSeriesListByGenre(genreId: $0)
}
.padding(.top, 30)
}
if !viewModel.newSeriesList.isEmpty {
ContentMainNewOrRecommendSeriesView(
title: "새로운 시리즈",
recommendSeriesList: viewModel.newSeriesList
)
.padding(.top, 30)
}
if !viewModel.rankCompleteSeriesList.isEmpty {
ContentMainCompletedSeriesView(
itemList: viewModel.rankCompleteSeriesList,
onClickMore: {}
)
.padding(.top, 30)
}
if !viewModel.seriesRankCreatorList.isEmpty {
SeriesByChannelView(
title: "채널별 추천 시리즈",
creatorList: viewModel.seriesRankCreatorList,
seriesList: viewModel.recommendSeriesByChannel
) {
viewModel.getRecommendSeriesByCreator(creatorId: $0)
}
.padding(.top, 30)
}
if !viewModel.eventBannerList.isEmpty {
SectionEventBannerView(items: viewModel.eventBannerList)
.frame(
width: viewModel.eventBannerList.count > 0 ? screenSize().width : 0,
height: viewModel.eventBannerList.count > 0 ? screenSize().width * 300 / 1000 : 0,
alignment: .center
)
.padding(.top, 30)
}
if !viewModel.curationList.isEmpty {
ContentMainSeriesCurationView(curationList: viewModel.curationList)
.padding(.top, 30)
}
}
.onAppear {
viewModel.fetchData()
}
}
}
}
}

View File

@ -6,3 +6,152 @@
//
import Foundation
import Combine
final class ContentMainTabSeriesViewModel: ObservableObject {
private let repository = ContentMainTabSeriesRepository()
private let contentRepository = ContentRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var bannerList: [GetAudioContentBannerResponse] = []
@Published var originalAudioDramaList: [SeriesListItem] = []
@Published var rankSeriesList: [SeriesListItem] = []
@Published var genreList: [GetSeriesGenreListResponse] = []
@Published var recommendSeriesList: [SeriesListItem] = []
@Published var newSeriesList: [GetRecommendSeriesListResponse] = []
@Published var rankCompleteSeriesList: [SeriesListItem] = []
@Published var seriesRankCreatorList: [ContentCreatorResponse] = []
@Published var recommendSeriesByChannel: [SeriesListItem] = []
@Published var eventBannerList: [EventItem] = []
@Published var curationList: [GetSeriesCurationResponse] = []
func fetchData() {
isLoading = true
repository.getContentMainSeries()
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<GetContentMainTabSeriesResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.bannerList = data.contentBannerList
self.originalAudioDramaList = data.originalAudioDrama
self.rankSeriesList = data.rankSeriesList
self.genreList = data.genreList
self.recommendSeriesList = data.recommendSeriesList
self.newSeriesList = data.newSeriesList
self.rankCompleteSeriesList = data.rankCompleteSeriesList
self.seriesRankCreatorList = data.seriesRankCreatorList
self.recommendSeriesByChannel = data.recommendSeriesByChannel
self.eventBannerList = data.eventBannerList.eventList
self.curationList = data.curationList
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
}
self.isLoading = false
}
.store(in: &subscription)
}
func getRecommendSeriesListByGenre(genreId: Int) {
isLoading = true
repository.getRecommendSeriesListByGenre(genreId: genreId)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<[SeriesListItem]>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.recommendSeriesList = data
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
}
self.isLoading = false
}
.store(in: &subscription)
}
func getRecommendSeriesByCreator(creatorId: Int) {
seriesRankCreatorList = []
isLoading = true
repository.getRecommendSeriesByCreator(creatorId: creatorId)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<[SeriesListItem]>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.recommendSeriesByChannel = data
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
}
self.isLoading = false
}
.store(in: &subscription)
}
}

View File

@ -5,4 +5,35 @@
// Created by klaus on 2/20/25.
//
import Foundation
struct GetContentMainTabSeriesResponse: Decodable {
let contentBannerList: [GetAudioContentBannerResponse]
let originalAudioDrama: [SeriesListItem]
let rankSeriesList: [SeriesListItem]
let genreList: [GetSeriesGenreListResponse]
let recommendSeriesList: [SeriesListItem]
let newSeriesList: [GetRecommendSeriesListResponse]
let rankCompleteSeriesList: [SeriesListItem]
let seriesRankCreatorList: [ContentCreatorResponse]
let recommendSeriesByChannel: [SeriesListItem]
let eventBannerList: GetEventResponse
let curationList: [GetSeriesCurationResponse]
}
struct GetSeriesGenreListResponse: Decodable {
let id: Int
let genre: String
}
struct GetRecommendSeriesListResponse: Decodable {
let seriesId: Int
let title: String
let imageUrl: String
let creatorId: Int
let creatorNickname: String
let creatorProfileImageUrl: String
}
struct GetSeriesCurationResponse: Decodable {
let title: String
let items: [SeriesListItem]
}

View File

@ -6,13 +6,103 @@
//
import SwiftUI
import Kingfisher
struct ContentMainOriginalAudioDramaItemView: View {
let itemWidth: CGFloat
let item: SeriesListItem
let isAll: Bool
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
VStack(alignment: .leading, spacing: 8) {
ZStack {
KFImage(URL(string: item.coverImage))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: itemWidth,
height: (itemWidth * 636) / 450
)
)
.resizable()
.scaledToFill()
.frame(width: itemWidth, height: (itemWidth * 636) / 450, alignment: .center)
.cornerRadius(5)
.clipped()
.onTapGesture {
AppState.shared
.setAppStep(step: .seriesDetail(seriesId: item.seriesId))
}
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 3.3) {
if !item.isComplete && item.isNew {
SeriesItemBadgeView(title: "신작", backgroundColor: .button)
}
if item.isComplete {
SeriesItemBadgeView(title: "완결", backgroundColor: Color(hex: "002abd"))
}
if item.isPopular {
SeriesItemBadgeView(title: "인기", backgroundColor: Color(hex: "ec6033"))
}
Spacer()
if !isAll {
SeriesItemBadgeView(title: "\(item.numberOfContent)", backgroundColor: Color.gray33.opacity(0.7))
}
}
Spacer()
HStack {
Spacer()
if isAll {
SeriesItemBadgeView(title: "\(item.numberOfContent)", backgroundColor: Color.gray33.opacity(0.7))
}
}
}
.padding(3.3)
}
.frame(width: itemWidth, height: (itemWidth * 636) / 450, alignment: .center)
Text(item.title)
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color.grayee)
.lineLimit(1)
if isAll {
Text(item.publishedDaysOfWeek)
.font(.custom(Font.medium.rawValue, size: 11))
.foregroundColor(Color.gray77)
}
}
.frame(width: itemWidth)
}
}
#Preview {
ContentMainOriginalAudioDramaItemView()
ContentMainOriginalAudioDramaItemView(
itemWidth: 150,
item: SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: false,
isPopular: true
),
isAll: false
)
}

View File

@ -8,11 +8,75 @@
import SwiftUI
struct ContentMainOriginalAudioDramaView: View {
let itemList: [SeriesListItem]
let onClickMore: () -> Void
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
VStack(spacing: 13.3) {
HStack(spacing: 0) {
Text("오리지널 오디오 드라마")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(.grayee)
Spacer()
Image("ic_forward")
.onTapGesture { onClickMore() }
}
.padding(.horizontal, 13.3)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 13.3) {
ForEach(0..<itemList.count, id: \.self) { index in
let item = itemList[index]
ContentMainOriginalAudioDramaItemView(
itemWidth: 150,
item: item,
isAll: false
)
}
}
.padding(.horizontal, 13.3)
}
}
}
}
#Preview {
ContentMainOriginalAudioDramaView()
ContentMainOriginalAudioDramaView(
itemList: [
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: false,
isPopular: true
),
SeriesListItem(
seriesId: 2,
title: "제목2",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "랜덤",
isComplete: false,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: false
)
]
) {}
}

View File

@ -0,0 +1,123 @@
//
// SeriesByChannelView.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
struct SeriesByChannelView: View {
let title: String
let creatorList: [ContentCreatorResponse]
let seriesList: [SeriesListItem]
let onClickCreator: (Int) -> Void
@State private var selectedCreatorId = 0
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(title)
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color(hex: "eeeeee"))
.padding(.horizontal, 13.3)
ScrollView(.horizontal) {
HStack(spacing: 22) {
ForEach(0..<creatorList.count, id: \.self) { index in
let item = creatorList[index]
ContentCreatorView(
isSelected: item.creatorId == selectedCreatorId,
item: item
)
.onTapGesture {
let creatorId = item.creatorId
if creatorId != selectedCreatorId {
selectedCreatorId = creatorId
onClickCreator(creatorId)
}
}
}
}
.padding(.horizontal, 13.3)
}
if seriesList.isEmpty {
ContentMainNoItemView()
} else {
ScrollView(.horizontal) {
HStack(spacing: 22) {
ForEach(0..<seriesList.count, id: \.self) { index in
let item = seriesList[index]
SeriesListBigItemView(
item: item,
isVisibleCreator: true
)
}
}
.padding(.horizontal, 13.3)
}
}
}
.onAppear {
if !self.creatorList.isEmpty {
selectedCreatorId = creatorList[0].creatorId
}
}
}
}
#Preview {
SeriesByChannelView(
title: "채널별 추천 시리즈",
creatorList: [
ContentCreatorResponse(
creatorId: 1,
creatorNickname: "유저1",
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
),
ContentCreatorResponse(
creatorId: 2,
creatorNickname: "유저2",
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
)
],
seriesList: [
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
),
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
coverImage: "https://test-cf.sodalive.net/profile/default-profile.png",
publishedDaysOfWeek: "매주 수, 토요일",
isComplete: true,
creator: SeriesListItemCreator(
creatorId: 1,
nickname: "creator",
profileImage: "https://test-cf.sodalive.net/profile/default-profile.png"
),
numberOfContent: 10,
isNew: true,
isPopular: true
)
],
onClickCreator: { _ in }
)
}

View File

@ -215,6 +215,9 @@ struct ContentView: View {
case .searchChannel:
SearchChannelView()
case .contentMain(let startTab):
ContentMainViewV2(selectedTab: startTab)
default:
EmptyView()
.frame(width: 0, height: 0, alignment: .topLeading)