feat: 신규 홈 추가

This commit is contained in:
Yu Sung
2025-07-11 12:18:37 +09:00
parent fca5425e81
commit e121ec1ee4
12 changed files with 492 additions and 3 deletions

View File

@@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "ic_point.png",
"idiom" : "universal",
"scale" : "1x"
},
@@ -9,7 +10,6 @@
"scale" : "2x"
},
{
"filename" : "ic_point.png",
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,86 @@
//
// ContentItemView.swift
// SodaLive
//
// Created by klaus on 7/10/25.
//
import SwiftUI
import Kingfisher
struct ContentItemView: View {
let item: AudioContentMainItem
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ZStack(alignment: .top) {
KFImage(URL(string: item.coverImageUrl))
.cancelOnDisappear(true)
.resizable()
.scaledToFill()
.frame(width: 168, height: 168, alignment: .top)
.cornerRadius(16)
HStack(alignment: .top, spacing: 0) {
Text("신작")
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 3)
.background(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color(hex: "0001B1"), location: 0.24),
.init(color: Color(hex: "3B5FF1"), location: 1.0)
]),
startPoint: .top,
endPoint: .bottom
)
)
.cornerRadius(16, corners: [.topLeft, .bottomRight])
Spacer()
if item.isPointAvailable {
Image("ic_point")
.padding(.top, 6)
.padding(.trailing, 6)
}
}
}
Text(item.title)
.font(.custom(Font.medium.rawValue, size: 18))
.foregroundColor(.white)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(1)
.padding(.horizontal, 6)
.padding(.top, 8)
Text(item.creatorNickname)
.font(.custom(Font.medium.rawValue, size: 14))
.foregroundColor(Color(hex: "78909C"))
.lineLimit(1)
.padding(.horizontal, 6)
.padding(.top, 4)
}
.frame(width: 168)
.onTapGesture { AppState.shared.setAppStep(step: .contentDetail(contentId: item.contentId)) }
}
}
#Preview {
ContentItemView(
item: AudioContentMainItem(
contentId: 1,
creatorId: 1,
title: "동정개발일지",
coverImageUrl: "https://cf.sodalive.net/audio_content_cover/5696/5696-cover-50066e61-6633-445b-9ae1-3749554d3f08-9514-1750756003835",
creatorNickname: "오늘밤결제했습니다",
isPointAvailable: true
)
)
}

View File

@@ -0,0 +1,15 @@
//
// AudioContentMainItem.swift
// SodaLive
//
// Created by klaus on 7/10/25.
//
struct AudioContentMainItem: Decodable {
let contentId: Int
let creatorId: Int
let title: String
let coverImageUrl: String
let creatorNickname: String
let isPointAvailable: Bool
}

View File

@@ -0,0 +1,21 @@
//
// GetHomeResponse.swift
// SodaLive
//
// Created by klaus on 7/11/25.
//
struct GetHomeResponse: Decodable {
let liveList: [GetRoomListResponse]
let creatorRanking: [GetExplorerSectionCreatorResponse]
let latestContentThemeList: [String]
let latestContentList: [AudioContentMainItem]
let eventBannerList: GetEventResponse
let originalAudioDramaList: [SeriesListItem]
let auditionList: [GetAuditionListItem]
let dayOfWeekSeriesList: [SeriesListItem]
let contentRanking: [GetAudioContentRankingItem]
let recommendChannelList: [RecommendChannelResponse]
let freeContentList: [AudioContentMainItem]
let curationList: [GetContentCurationResponse]
}

View File

@@ -0,0 +1,73 @@
//
// HomeApi.swift
// SodaLive
//
// Created by klaus on 7/10/25.
//
import Foundation
import Moya
enum HomeApi {
case getHomeData(isAdultContentVisible: Bool, contentType: ContentType)
case getLatestContentByTheme(theme: String, isAdultContentVisible: Bool, contentType: ContentType)
case getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek, isAdultContentVisible: Bool, contentType: ContentType)
}
extension HomeApi: TargetType {
var baseURL: URL {
return URL(string: BASE_URL)!
}
var path: String {
switch self {
case .getHomeData:
return "/api/home"
case .getLatestContentByTheme:
return "/api/home/latest-content"
case .getDayOfWeekSeriesList:
return "/api/home/day-of-week-series"
}
}
var method: Moya.Method {
return .get
}
var task: Moya.Task {
switch self {
case .getHomeData(let isAdultContentVisible, let contentType):
let parameters = [
"timezone": TimeZone.current.identifier,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getLatestContentByTheme(let theme, let isAdultContentVisible, let contentType):
let parameters = [
"theme": theme,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getDayOfWeekSeriesList(let dayOfWeek, let isAdultContentVisible, let contentType):
let parameters = [
"dayOfWeek": dayOfWeek,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
}
}
var headers: [String : String]? {
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
}
}

View File

@@ -0,0 +1,18 @@
//
// HomeCurationView.swift
// SodaLive
//
// Created by klaus on 7/10/25.
//
import SwiftUI
struct HomeCurationView: View {
var body: some View {
VStack(spacing: 30) {}
}
}
#Preview {
HomeCurationView()
}

View File

@@ -0,0 +1,15 @@
//
// HomeTabRepository.swift
// SodaLive
//
// Created by klaus on 7/10/25.
//
import Foundation
import CombineMoya
import Combine
import Moya
class HomeTabRepository {
private let api = MoyaProvider<HomeApi>()
}

View File

@@ -0,0 +1,204 @@
//
// HomeTabView.swift
// SodaLive
//
// Created by klaus on 7/10/25.
//
import SwiftUI
struct HomeTabView: View {
@StateObject var viewModel = ContentMainTabHomeViewModel()
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
@AppStorage("role") private var role: String = UserDefaults.string(forKey: UserDefaultsKey.role)
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
ZStack(alignment: .bottomTrailing) {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) {
Image("img_text_logo")
Spacer()
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Image("ic_can")
.onTapGesture {
AppState
.shared
.setAppStep(step: .canCharge(refresh: {}))
}
}
}
.padding(.horizontal, 24)
.padding(.vertical, 20)
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 48) {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 0) {
Text("지금")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.button)
Text(" 라이브중")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.white)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
}
}
}
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 0) {
Text("인기")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.button)
Text(" 크리에이터")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.white)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
}
}
}
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 0) {
Text("최신")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.button)
Text(" 콘텐츠")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.white)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
}
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
}
}
}
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 0) {
Text("오직")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.button)
Text(" 보이스온에서만")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.white)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
}
}
}
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 0) {
Text("요일별")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.button)
Text(" 시리즈")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.white)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
}
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
}
}
}
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 0) {
Text("보온")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.button)
Text(" 주간 차트")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.white)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
}
}
}
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 0) {
Text("추천")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.button)
Text(" 채널")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.white)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
}
}
}
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 0) {
Text("무료")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.button)
Text(" 콘텐츠")
.font(.custom(Font.bold.rawValue, size: 26))
.foregroundColor(.white)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
}
}
}
}
.padding(24)
}
}
}
}
}
}
#Preview {
HomeTabView()
}

View File

@@ -0,0 +1,35 @@
//
// HomeTabViewModel.swift
// SodaLive
//
// Created by klaus on 7/10/25.
//
import Foundation
import Combine
enum SeriesPublishedDaysOfWeek: String, Encodable {
case SUN, MON, TUE, WED, THU, FRI, SAT, RANDOM
}
final class HomeTabViewModel: ObservableObject {
private let repository = HomeTabRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var liveList: [GetRoomListResponse] = []
@Published var creatorRanking: [GetExplorerSectionCreatorResponse] = []
@Published var latestContentThemeList: [String] = []
@Published var latestContentList: [AudioContentMainItem] = []
@Published var eventBannerList: GetEventResponse? = nil
@Published var originalAudioDramaList: [SeriesListItem] = []
@Published var auditionList: [GetAuditionListItem] = []
@Published var dayOfWeekSeriesList: [SeriesListItem] = []
@Published var contentRanking: [GetAudioContentRankingItem] = []
@Published var recommendChannelList: [RecommendChannelResponse] = []
@Published var freeContentList: [AudioContentMainItem] = []
@Published var curationList: [GetContentCurationResponse] = []
}

View File

@@ -0,0 +1,22 @@
//
// RecommendChannelResponse.swift
// SodaLive
//
// Created by klaus on 7/11/25.
//
struct RecommendChannelResponse: Decodable {
let channelId: Int
let creatorNickname: String
let creatorProfileImageUrl: String
let contentCount: Int
let contentList: [RecommendChannelContentItem]
}
struct RecommendChannelContentItem: Decodable {
let contentId: Int
let title: String
let thumbnailImageUrl: String
let likeCount: Int
let commentCount: Int
}

View File

@@ -18,7 +18,7 @@ struct HomeView: View {
@StateObject var contentPlayerPlayManager = ContentPlayerPlayManager.shared
private let liveView = LiveView()
private let contentView = ContentMainTabHomeView()
private let homeTabView = HomeTabView()
@State private var isShowPlayer = false
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
@@ -28,7 +28,7 @@ struct HomeView: View {
ZStack(alignment: .bottom) {
VStack(spacing: 0) {
ZStack {
contentView
homeTabView
.frame(width: viewModel.currentTab == .home ? proxy.size.width : 0)
.opacity(viewModel.currentTab == .home ? 1.0 : 0.01)