콘텐츠 메인

- 홈 UI 페이지 생성
This commit is contained in:
Yu Sung
2025-02-20 23:31:59 +09:00
parent e641636007
commit 48ebc1eaef
35 changed files with 1517 additions and 4 deletions

View File

@@ -0,0 +1,38 @@
//
// ContentMainTabCategoryView.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import SwiftUI
struct ContentMainTabCategoryView: View {
let imageName: String
let title: String
let onClick: () -> Void
var body: some View {
VStack(spacing: 5.3) {
Image(imageName)
.resizable()
.frame(width: 43, height: 43)
Text(title)
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(.gray77)
}
.onTapGesture {
onClick()
}
}
}
#Preview {
ContentMainTabCategoryView(
imageName: "ic_category_series",
title: "시리즈",
onClick: {}
)
}

View File

@@ -0,0 +1,47 @@
//
// ContentMainTabHomeNoticeView.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import SwiftUI
struct ContentMainTabHomeNoticeView: View {
let notice: NoticeItem
let onClick: (NoticeItem) -> Void
var body: some View {
HStack(spacing: 0) {
Text(notice.title)
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(.white)
Spacer()
Text("자세히 >")
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(.white)
.onTapGesture {
onClick(notice)
}
}
.padding(.horizontal, 13.3)
.padding(.vertical, 10)
.background(Color.gray22)
.cornerRadius(5.3)
}
}
#Preview {
ContentMainTabHomeNoticeView(
notice: NoticeItem(
title: "[업데이트] 1.28.0 버전 업데이트",
content: "test",
date: "2025-02-07"
)
) {
AppState.shared.setAppStep(step: .noticeDetail(notice: $0))
}
}

View File

@@ -0,0 +1,23 @@
//
// ContentMainTabHomeRepository.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import Foundation
import CombineMoya
import Combine
import Moya
class ContentMainTabHomeRepository {
private let api = MoyaProvider<ContentApi>()
func getContentMainHome() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getContentMainHome)
}
func getPopularContentByCreator(creatorId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getPopularContentByCreator(creatorId: creatorId))
}
}

View File

@@ -0,0 +1,238 @@
//
// ContentMainTabHomeView.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import SwiftUI
struct ContentMainTabHomeView: View {
@StateObject var viewModel = ContentMainTabHomeViewModel()
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, 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(.bottom, 26.7)
.padding(.horizontal, 13.3)
if let notice = viewModel.noticeItem {
ContentMainTabHomeNoticeView(notice: notice) {
AppState.shared
.setAppStep(step: .noticeDetail(notice: $0))
}
.padding(.horizontal, 13.3)
}
if viewModel.bannerList.count > 0 {
ContentMainBannerViewV2(bannerList: viewModel.bannerList)
.padding(.top, 30)
.padding(.horizontal, 13.3)
}
HStack(spacing: 0) {
Image("ic_title_search_black")
Text("채널명을 입력해 보세요")
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(Color.gray55)
.keyboardType(.default)
.padding(.horizontal, 13.3)
Spacer()
}
.padding(.horizontal, 21.3)
.frame(height: 50)
.frame(maxWidth: .infinity)
.background(Color.gray22)
.overlay(
RoundedRectangle(cornerRadius: 6.7)
.strokeBorder(lineWidth: 1)
.foregroundColor(Color.graybb)
)
.padding(.top, 30)
.padding(.horizontal, 13.3)
.onTapGesture {
UserDefaults.set("", forKey: .searchChannel)
AppState.shared.setAppStep(step: .searchChannel)
}
VStack(spacing: 13.3) {
HStack(spacing: 0) {
ContentMainTabCategoryView(
imageName: "ic_category_series",
title: "시리즈",
onClick: {}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_content",
title: "단편",
onClick: {}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_audio_book",
title: "오디오북",
onClick: {}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_alarm",
title: "모닝콜",
onClick: {}
)
.frame(maxWidth: .infinity)
}
HStack(spacing: 0) {
ContentMainTabCategoryView(
imageName: "ic_category_asmr",
title: "ASMR",
onClick: {}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_replay",
title: "다시듣기",
onClick: {}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_audio_toon",
title: "오디오툰",
onClick: {}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_free",
title: "무료",
onClick: {}
)
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 13.3)
.background(Color.gray22)
.cornerRadius(5.3)
.padding(.top, 30)
.padding(.horizontal, 13.3)
if let response = viewModel.rankCreatorResponse {
ContentMainTabHomeRankCreatorView(response: response)
.padding(.top, 30)
.padding(.horizontal, 13.3)
}
if !viewModel.rankSeriesList.isEmpty {
ContentMainTabHomeRankSeriesView(seriesList: viewModel.rankSeriesList)
.padding(.top, 30)
.padding(.horizontal, 13.3)
}
if !viewModel.rankSortTypeList.isEmpty {
ContentMainTabRankContentView(
title: "인기 단편",
isMore: true,
onClickMore: {
AppState.shared.setAppStep(step: .contentRankingAll)
},
sortList: !viewModel.rankSortTypeList.isEmpty ?
viewModel.rankSortTypeList :
[],
onClickSort: { viewModel.getContentRanking(sort: $0) },
contentList: viewModel.rankContentList
)
.padding(.top, 30)
.padding(.horizontal, 13.3)
}
if viewModel.eventBannerList.count > 0 {
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.contentRankCreatorList.isEmpty {
ContentByChannelView(
title: "채널별 인기 콘텐츠",
creatorList: viewModel.contentRankCreatorList,
contentList: viewModel.salesCountRankContentList,
onClickCreator: {
viewModel.getPopularContentByCreator(creatorId: $0)
}
)
.padding(.top, 30)
.padding(.horizontal, 13.3)
}
Text("""
- 회사명 : 주식회사 소다라이브
- 대표자 : 이재형
- 주소 : 경기도 성남시 분당구 황새울로335번길 10, 5층 563A호
- 사업자등록번호 : 870-81-03220
- 통신판매업신고 : 제2024-성남분당B-1012호
- 고객센터 : 02.2055.1477 (이용시간 10:00~19:00)
- 대표 이메일 : sodalive.official@gmail.com
""")
.font(.custom(Font.medium.rawValue, size: 11))
.foregroundColor(Color.gray77)
.padding(.top, 30)
.padding(.horizontal, 13.3)
}
.onAppear {
viewModel.fetchData()
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.font(.custom(Font.medium.rawValue, size: 12))
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
}
}
}
#Preview {
ContentMainTabHomeView()
}

View File

@@ -0,0 +1,152 @@
//
// ContentMainTabHomeViewModel.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import Foundation
import Combine
final class ContentMainTabHomeViewModel: ObservableObject {
private let repository = ContentMainTabHomeRepository()
private let contentRepository = ContentRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var noticeItem: NoticeItem? = nil
@Published var bannerList = [GetAudioContentBannerResponse]()
@Published var rankCreatorResponse: GetExplorerSectionResponse? = nil
@Published var rankSeriesList = [SeriesListItem]()
@Published var rankSortTypeList: [String] = []
@Published var rankContentList: [GetAudioContentRankingItem] = []
@Published var eventBannerList: [EventItem] = []
@Published var contentRankCreatorList: [ContentCreatorResponse] = []
@Published var salesCountRankContentList: [GetAudioContentRankingItem] = []
func fetchData() {
isLoading = true
repository.getContentMainHome()
.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<GetContentMainTabHomeResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.noticeItem = data.latestNotice
self.bannerList = data.bannerList
self.rankCreatorResponse = data.rankCreatorList
self.rankSortTypeList = data.rankSortTypeList
self.rankContentList = data.rankContentList
self.eventBannerList = data.eventBannerList.eventList
self.contentRankCreatorList = data.contentRankCreatorList
self.salesCountRankContentList = data.salesCountRankContentList
} 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 getContentRanking(sort: String = "매출") {
isLoading = true
contentRepository.getContentRanking(page: 1, size: 12, sortType: sort)
.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<GetAudioContentRanking>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.rankContentList = data.items
} 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 getPopularContentByCreator(creatorId: Int) {
isLoading = true
repository.getPopularContentByCreator(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<[GetAudioContentRankingItem]>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.salesCountRankContentList = 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

@@ -0,0 +1,18 @@
//
// GetContentMainTabHomeResponse.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
struct GetContentMainTabHomeResponse: Decodable {
let latestNotice: NoticeItem?
let bannerList: [GetAudioContentBannerResponse]
let rankCreatorList: GetExplorerSectionResponse
let rankSeriesList: [SeriesListItem]
let rankSortTypeList: [String]
let rankContentList: [GetAudioContentRankingItem]
let eventBannerList: GetEventResponse
let contentRankCreatorList: [ContentCreatorResponse]
let salesCountRankContentList: [GetAudioContentRankingItem]
}

View File

@@ -0,0 +1,170 @@
//
// ContentMainTabHomeRankCreatorView.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import SwiftUI
import Kingfisher
struct ContentMainTabHomeRankCreatorView: View {
let response: GetExplorerSectionResponse
let rankingCrawns = ["ic_crown_1", "ic_crown_2", "ic_crown_3"]
let rankingColors = [
[Color(hex: "ffdc00"), Color(hex: "ffb600")],
[Color(hex: "ffffff"), Color(hex: "9f9f9f")],
[Color(hex: "e6a77a"), Color(hex: "c67e4a")],
[Color(hex: "ffffff").opacity(0), Color(hex: "ffffff").opacity(0)]
]
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let desc = response.desc {
VStack(spacing: 8) {
Text("\(desc)")
.font(.custom(Font.bold.rawValue, size: 14.7))
.foregroundColor(Color.grayee)
Text("※ 인기 순위는 매주 업데이트됩니다.")
.font(.custom(Font.light.rawValue, size: 13.3))
.foregroundColor(Color.graybb)
}
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(Color.gray22)
.padding(.top, 13.3)
}
if let coloredTitle = response.coloredTitle, let color = response.color {
let titleArray = response.title.components(separatedBy: coloredTitle)
HStack(spacing: 0) {
Text(titleArray[0])
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color.grayee)
Text(coloredTitle)
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color(hex: color))
if titleArray.count > 1 {
Text(titleArray[1])
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color.grayee)
}
}
.padding(.top, 30)
} else {
Text(response.title)
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color.grayee)
.padding(.top, 30)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 13.3) {
ForEach(0..<response.creators.count, id: \.self) { index in
let creator = response.creators[index]
VStack(spacing: 0) {
if let _ = response.desc {
ZStack {
KFImage(URL(string: creator.profileImageUrl))
.cancelOnDisappear(true)
.downsampling(size: CGSize(width: 90, height: 90))
.resizable()
.clipShape(Circle())
.frame(width: 90, height: 90)
.overlay(
Circle()
.stroke(
AngularGradient(colors: rankingColors[index < 4 ? index : 3], center: .center),
lineWidth: 3
)
)
if index < 3 {
VStack(alignment: .trailing, spacing: 0) {
Spacer()
Image(rankingCrawns[index])
.resizable()
.frame(width: 37, height: 37)
}
.frame(width: 93.3, height: 93.3, alignment: .trailing)
}
}
.frame(width: 93.3, height: 93.3)
} else {
KFImage(URL(string: creator.profileImageUrl))
.cancelOnDisappear(true)
.downsampling(size: CGSize(width: 93, height: 93))
.resizable()
.clipShape(Circle())
.frame(width: 93, height: 93)
}
Text(creator.nickname)
.font(.custom(Font.medium.rawValue, size: 11.3))
.foregroundColor(Color.grayee)
.lineLimit(1)
.frame(width: 93.3)
.padding(.top, 13.3)
Text(creator.tags)
.font(.custom(Font.medium.rawValue, size: 10))
.foregroundColor(Color.button)
.lineLimit(1)
.frame(width: 93.3)
.padding(.top, 3.3)
}
.contentShape(Rectangle())
.onTapGesture {
AppState.shared
.setAppStep(step: .creatorDetail(userId: creator.id))
}
}
}
}
.padding(.top, 13.3)
}
}
}
#Preview {
ContentMainTabHomeRankCreatorView(
response: GetExplorerSectionResponse(
title: "인기 크리에이터",
coloredTitle: "인기",
color: "ff5c49",
desc: "2025년 02월 10일 ~ 02월 16일",
creators: [
GetExplorerSectionCreatorResponse(
id: 1,
nickname: "User1",
tags: "",
profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
),
GetExplorerSectionCreatorResponse(
id: 2,
nickname: "User2",
tags: "",
profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
),
GetExplorerSectionCreatorResponse(
id: 3,
nickname: "User3",
tags: "",
profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
),
GetExplorerSectionCreatorResponse(
id: 4,
nickname: "User4",
tags: "",
profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
)
]
)
)
}

View File

@@ -0,0 +1,82 @@
//
// ContentMainTabHomeRankSeriesView.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import SwiftUI
struct ContentMainTabHomeRankSeriesView: View {
let seriesList: [SeriesListItem]
var body: some View {
VStack(alignment: .leading, spacing: 13.3) {
Text("인기 시리즈")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color.grayee)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 13.3) {
ForEach(0..<seriesList.count, id: \.self) {
let item = seriesList[$0]
SeriesListBigItemView(item: item, isVisibleCreator: true)
}
}
}
}
}
}
#Preview {
ContentMainTabHomeRankSeriesView(
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: 2,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
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: false,
isPopular: true
),
SeriesListItem(
seriesId: 1,
title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)",
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
)
]
)
}