feat: 신규 홈 추가
This commit is contained in:
@@ -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 |
86
SodaLive/Sources/Content/ContentItemView.swift
Normal file
86
SodaLive/Sources/Content/ContentItemView.swift
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
15
SodaLive/Sources/Home/AudioContentMainItem.swift
Normal file
15
SodaLive/Sources/Home/AudioContentMainItem.swift
Normal 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
|
||||
}
|
||||
21
SodaLive/Sources/Home/GetHomeResponse.swift
Normal file
21
SodaLive/Sources/Home/GetHomeResponse.swift
Normal 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]
|
||||
}
|
||||
73
SodaLive/Sources/Home/HomeApi.swift
Normal file
73
SodaLive/Sources/Home/HomeApi.swift
Normal 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))"]
|
||||
}
|
||||
}
|
||||
18
SodaLive/Sources/Home/HomeCurationView.swift
Normal file
18
SodaLive/Sources/Home/HomeCurationView.swift
Normal 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()
|
||||
}
|
||||
15
SodaLive/Sources/Home/HomeTabRepository.swift
Normal file
15
SodaLive/Sources/Home/HomeTabRepository.swift
Normal 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>()
|
||||
}
|
||||
204
SodaLive/Sources/Home/HomeTabView.swift
Normal file
204
SodaLive/Sources/Home/HomeTabView.swift
Normal 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()
|
||||
}
|
||||
35
SodaLive/Sources/Home/HomeTabViewModel.swift
Normal file
35
SodaLive/Sources/Home/HomeTabViewModel.swift
Normal 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] = []
|
||||
}
|
||||
22
SodaLive/Sources/Home/RecommendChannelResponse.swift
Normal file
22
SodaLive/Sources/Home/RecommendChannelResponse.swift
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user