diff --git a/SodaLive/Resources/Assets.xcassets/ic_point.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_point.imageset/Contents.json index 7477522..963aa8f 100644 --- a/SodaLive/Resources/Assets.xcassets/ic_point.imageset/Contents.json +++ b/SodaLive/Resources/Assets.xcassets/ic_point.imageset/Contents.json @@ -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" } diff --git a/SodaLive/Resources/Assets.xcassets/ic_point.imageset/ic_point.png b/SodaLive/Resources/Assets.xcassets/ic_point.imageset/ic_point.png index d41e9a9..27aa8dc 100644 Binary files a/SodaLive/Resources/Assets.xcassets/ic_point.imageset/ic_point.png and b/SodaLive/Resources/Assets.xcassets/ic_point.imageset/ic_point.png differ diff --git a/SodaLive/Sources/Content/ContentItemView.swift b/SodaLive/Sources/Content/ContentItemView.swift new file mode 100644 index 0000000..728d963 --- /dev/null +++ b/SodaLive/Sources/Content/ContentItemView.swift @@ -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 + ) + ) +} diff --git a/SodaLive/Sources/Home/AudioContentMainItem.swift b/SodaLive/Sources/Home/AudioContentMainItem.swift new file mode 100644 index 0000000..04c51ec --- /dev/null +++ b/SodaLive/Sources/Home/AudioContentMainItem.swift @@ -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 +} diff --git a/SodaLive/Sources/Home/GetHomeResponse.swift b/SodaLive/Sources/Home/GetHomeResponse.swift new file mode 100644 index 0000000..6d5a568 --- /dev/null +++ b/SodaLive/Sources/Home/GetHomeResponse.swift @@ -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] +} diff --git a/SodaLive/Sources/Home/HomeApi.swift b/SodaLive/Sources/Home/HomeApi.swift new file mode 100644 index 0000000..9e6f667 --- /dev/null +++ b/SodaLive/Sources/Home/HomeApi.swift @@ -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))"] + } +} diff --git a/SodaLive/Sources/Home/HomeCurationView.swift b/SodaLive/Sources/Home/HomeCurationView.swift new file mode 100644 index 0000000..22f15d7 --- /dev/null +++ b/SodaLive/Sources/Home/HomeCurationView.swift @@ -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() +} diff --git a/SodaLive/Sources/Home/HomeTabRepository.swift b/SodaLive/Sources/Home/HomeTabRepository.swift new file mode 100644 index 0000000..8c6c32a --- /dev/null +++ b/SodaLive/Sources/Home/HomeTabRepository.swift @@ -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() +} diff --git a/SodaLive/Sources/Home/HomeTabView.swift b/SodaLive/Sources/Home/HomeTabView.swift new file mode 100644 index 0000000..1a97643 --- /dev/null +++ b/SodaLive/Sources/Home/HomeTabView.swift @@ -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() +} diff --git a/SodaLive/Sources/Home/HomeTabViewModel.swift b/SodaLive/Sources/Home/HomeTabViewModel.swift new file mode 100644 index 0000000..80a6c62 --- /dev/null +++ b/SodaLive/Sources/Home/HomeTabViewModel.swift @@ -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() + + @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] = [] +} diff --git a/SodaLive/Sources/Home/RecommendChannelResponse.swift b/SodaLive/Sources/Home/RecommendChannelResponse.swift new file mode 100644 index 0000000..5add07c --- /dev/null +++ b/SodaLive/Sources/Home/RecommendChannelResponse.swift @@ -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 +} diff --git a/SodaLive/Sources/Main/Home/HomeView.swift b/SodaLive/Sources/Main/Home/HomeView.swift index 479d848..5d0b2bc 100644 --- a/SodaLive/Sources/Main/Home/HomeView.swift +++ b/SodaLive/Sources/Main/Home/HomeView.swift @@ -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)