Compare commits

5 Commits

Author SHA1 Message Date
Yu Sung
0a22f87acc refactor: 사용하지 않는 파일 삭제 2026-03-18 19:45:45 +09:00
Yu Sung
8b102905ad refactor: 사용하지 않는 파일 삭제 2026-03-18 19:14:54 +09:00
Yu Sung
e9fc7e180d fix(live): 라이브룸 후원·하트 랭킹 왕관 UI를 동일화한다 2026-03-18 14:26:00 +09:00
Yu Sung
84d8e2f2e3 fix(chat): 채팅 왕관 오버레이 정렬 2026-03-18 14:06:43 +09:00
Yu Sung
b51d643db8 feat(home): 홈 탭 지연 로딩 및 상태 유지 구현 2026-03-18 11:30:04 +09:00
85 changed files with 197 additions and 6880 deletions

View File

@@ -1276,6 +1276,7 @@
} }
}, },
"※ 인기 순위는 매주 업데이트됩니다." : { "※ 인기 순위는 매주 업데이트됩니다." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -1324,6 +1325,7 @@
} }
}, },
"※ 최근 2주간 등록된 새로운 ASMR 입니다." : { "※ 최근 2주간 등록된 새로운 ASMR 입니다." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -1340,6 +1342,7 @@
} }
}, },
"※ 최근 2주간 등록된 새로운 라이브 다시듣기 입니다." : { "※ 최근 2주간 등록된 새로운 라이브 다시듣기 입니다." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -1356,6 +1359,7 @@
} }
}, },
"※ 최근 2주간 등록된 새로운 알람 입니다." : { "※ 최근 2주간 등록된 새로운 알람 입니다." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -3938,6 +3942,7 @@
} }
}, },
"마이페이지에서 본인인증을 해주세요" : { "마이페이지에서 본인인증을 해주세요" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -4129,22 +4134,6 @@
} }
} }
}, },
"모집완료" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recruitment closed"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "募集終了"
}
}
}
},
"목" : { "목" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -4179,6 +4168,22 @@
}, },
"모서리 원을 드래그해서 크롭 영역 크기를 조정하세요" : { "모서리 원을 드래그해서 크롭 영역 크기를 조정하세요" : {
},
"모집완료" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recruitment closed"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "募集終了"
}
}
}
}, },
"모집중" : { "모집중" : {
"localizations" : { "localizations" : {
@@ -4597,6 +4602,7 @@
} }
}, },
"보이스 모닝콜" : { "보이스 모닝콜" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5173,6 +5179,7 @@
} }
}, },
"새로운 콘텐츠" : { "새로운 콘텐츠" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5384,6 +5391,7 @@
} }
}, },
"숏플" : { "숏플" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -6459,6 +6467,7 @@
} }
}, },
"오리지널 오디오 드라마" : { "오리지널 오디오 드라마" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -7051,6 +7060,7 @@
} }
}, },
"인기 시리즈" : { "인기 시리즈" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -7179,6 +7189,7 @@
} }
}, },
"일간 랭킹" : { "일간 랭킹" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -7339,6 +7350,7 @@
} }
}, },
"자세히 >" : { "자세히 >" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -7515,6 +7527,7 @@
} }
}, },
"장르별 추천 시리즈" : { "장르별 추천 시리즈" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -8801,6 +8814,7 @@
} }
}, },
"콘텐츠 마켓" : { "콘텐츠 마켓" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -9121,6 +9135,7 @@
} }
}, },
"태그별 추천 콘텐츠" : { "태그별 추천 콘텐츠" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {

View File

@@ -150,18 +150,6 @@ enum AppStep {
case search case search
case contentMain(startTab: ContentMainTab)
case completedSeriesAll
case newAlarmContentAll
case newAsmrContentAll
case newReplayContentAll
case introduceCreatorAll
case message case message
case notificationList case notificationList

View File

@@ -1,188 +0,0 @@
//
// ContentMainView.swift
// SodaLive
//
// Created by klaus on 2023/08/09.
//
import SwiftUI
struct ContentMainView: View {
@StateObject var viewModel = ContentMainViewModel()
var body: some View {
ZStack(alignment: .bottomTrailing) {
Color.black.ignoresSafeArea()
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) {
Text("콘텐츠 마켓")
.appFont(size: 21.3, weight: .bold)
.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 !viewModel.isLoading {
ContentMainBannerView()
.padding(.bottom, 26.7)
.padding(.horizontal, 13.3)
HStack(spacing: 0) {
Image("ic_title_search_black")
Text("채널명을 입력해 보세요")
.appFont(size: 13.3, weight: .medium)
.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(.bottom, 40)
.padding(.horizontal, 13.3)
.onTapGesture {
UserDefaults.set("", forKey: .searchChannel)
AppState.shared.setAppStep(step: .search)
}
ContentMainCreatorRankingView()
.padding(.bottom, 40)
.padding(.horizontal, 13.3)
ContentMainRecommendSeriesView()
HStack(spacing: 8) {
ZStack {
Image("img_bg_morning_call")
.resizable()
.frame(height: 53.3)
.frame(maxWidth: .infinity)
.cornerRadius(2.6)
HStack(spacing: 2.7) {
Image("ic_alarm_clock_blue")
Text("보이스 모닝콜")
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color(hex: "0057ff"))
}
.cornerRadius(2.6)
}
.onTapGesture {
AppState.shared.setAppStep(
step: .contentAllByTheme(themeId: 12)
)
}
ZStack {
Image("img_bg_short_play")
.resizable()
.frame(height: 53.3)
.frame(maxWidth: .infinity)
.cornerRadius(2.6)
HStack(spacing: 2.7) {
Image("ic_short_play")
Text("숏플")
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color(hex: "dd158d"))
}
.cornerRadius(2.6)
}
.onTapGesture {
AppState.shared.setAppStep(
step: .contentAllByTheme(themeId: 11)
)
}
}
.padding(.bottom, 40)
.padding(.horizontal, 13.3)
ContentMainNewContentView()
.padding(.horizontal, 13.3)
ContentMainRankingView()
.padding(.top, 40)
.padding(.horizontal, 13.3)
.animation(nil)
ContentMainCurationView()
.padding(.top, 40)
.padding(.bottom, 20)
Text("""
- 회사명 : 주식회사 소다라이브
- 대표자 : 이재형
- 주소 : 경기도 성남시 분당구 황새울로335번길 10, 5층 563A호
- 사업자등록번호 : 870-81-03220
- 통신판매업신고 : 제2024-성남분당B-1012호
- 고객센터 : 02.2055.1477 (이용시간 10:00~19:00)
- 대표 이메일 : sodalive.official@gmail.com
""")
.appFont(size: 11, weight: .medium)
.foregroundColor(Color.gray77)
.padding(.top, 13.3)
.padding(.horizontal, 13.3)
}
}
.padding(.vertical, 13.3)
}
if UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue {
HStack(spacing: 5) {
Image("ic_thumb_play")
.resizable()
.frame(width: 20, height: 20)
Text("콘텐츠 업로드")
.appFont(size: 13.3, weight: .bold)
.foregroundColor(.white)
}
.padding(13.3)
.background(Color(hex: "3bb9f1"))
.cornerRadius(44)
.padding(.trailing, 16.7)
.padding(.bottom, 16.7)
.onTapGesture {
AppState.shared.setAppStep(step: .createContent)
}
}
}
if viewModel.isLoading {
LoadingView()
}
}
}
struct ContentMainView_Previews: PreviewProvider {
static var previews: some View {
ContentMainView()
}
}

View File

@@ -1,22 +0,0 @@
//
// ContentMainViewModel.swift
// SodaLive
//
// Created by klaus on 2023/08/11.
//
import Foundation
import Combine
final class ContentMainViewModel: ObservableObject {
@Published var isLoading = false
func refresh() {
isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [unowned self] in
self.isLoading = false
}
}
}

View File

@@ -1,53 +0,0 @@
//
// ContentMainCurationItemView.swift
// SodaLive
//
// Created by klaus on 2023/08/11.
//
import SwiftUI
struct ContentMainCurationItemView: View {
let item: GetAudioContentCurationResponse
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) {
Text(item.title)
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "eeeeee"))
Spacer()
Image("ic_forward")
.resizable()
.frame(width: 20, height: 20)
.onTapGesture {
AppState.shared
.setAppStep(
step: .curationAll(
title: item.title,
curationId: item.curationId
)
)
}
}
Text(item.description)
.appFont(size: 13, weight: .medium)
.foregroundColor(Color(hex: "777777"))
.padding(.top, 4)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 13.3) {
ForEach(0..<item.contents.count, id: \.self) {
let audioContent = item.contents[$0]
ContentMainItemView(item: audioContent)
}
}
}
.padding(.top, 13.3)
}
}
}

View File

@@ -1,42 +0,0 @@
//
// ContentMainCurationView.swift
// SodaLive
//
// Created by klaus on 2023/08/11.
//
import SwiftUI
struct ContentMainCurationView: View {
@StateObject private var viewModel = ContentMainCurationViewModel()
var body: some View {
VStack {
if !viewModel.curationList.isEmpty {
LazyVStack(spacing: 40) {
ForEach(0..<viewModel.curationList.count, id: \.self) { index in
ContentMainCurationItemView(item: viewModel.curationList[index])
.padding(.horizontal, 13.3)
.onAppear {
if index == viewModel.curationList.count - 1 {
viewModel.getCurationList()
}
}
}
}
}
if viewModel.isLoading {
ActivityIndicatorView()
.frame(width: 100, height: 100)
}
}
.frame(maxWidth: .infinity)
.onAppear {
viewModel.page = 1
viewModel.isLast = false
viewModel.getCurationList()
}
}
}

View File

@@ -1,76 +0,0 @@
//
// ContentMainCurationViewModel.swift
// SodaLive
//
// Created by klaus on 2023/12/11.
//
import Foundation
import Combine
final class ContentMainCurationViewModel: ObservableObject {
private let repository = ContentRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var curationList = [GetAudioContentCurationResponse]()
var isLast = false
var page = 1
private let size = 10
func getCurationList() {
if !isLoading && !isLast {
isLoading = true
repository.getCurationList(page: page, size: size)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
self.isLoading = false
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<[GetAudioContentCurationResponse]>.self, from: responseData)
self.isLoading = false
if let data = decoded.data, decoded.success {
if page == 1 {
self.curationList.removeAll()
}
if !data.isEmpty {
page += 1
self.curationList.append(contentsOf: data)
} else {
isLast = true
}
} 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

@@ -7,17 +7,6 @@
import Foundation import Foundation
struct GetAudioContentMainResponse: Decodable {
let newContentUploadCreatorList: [ContentCreatorResponse]
let bannerList: [GetAudioContentBannerResponse]
let orderList: [GetAudioContentMainItem]
let themeList: [String]
let newContentList: [GetAudioContentMainItem]
let curationList: [GetAudioContentCurationResponse]
let contentRankingSortTypeList: [String]
let contentRanking: GetAudioContentRanking
}
struct GetAudioContentRanking: Decodable { struct GetAudioContentRanking: Decodable {
let startDate: String let startDate: String
let endDate: String let endDate: String
@@ -37,12 +26,6 @@ struct GetAudioContentRankingItem: Decodable {
let creatorProfileImageUrl: String let creatorProfileImageUrl: String
} }
struct ContentCreatorResponse: Decodable {
let creatorId: Int
let creatorNickname: String
let creatorProfileImageUrl: String
}
struct GetAudioContentMainItem: Decodable { struct GetAudioContentMainItem: Decodable {
let contentId: Int let contentId: Int
let coverImageUrl: String let coverImageUrl: String
@@ -55,13 +38,6 @@ struct GetAudioContentMainItem: Decodable {
let isPointAvailable: Bool let isPointAvailable: Bool
} }
struct GetAudioContentCurationResponse: Decodable {
let curationId: Int
let title: String
let description: String
let contents: [GetAudioContentMainItem]
}
struct GetAudioContentBannerResponse: Decodable { struct GetAudioContentBannerResponse: Decodable {
let type: AudioContentBannerType let type: AudioContentBannerType
let thumbnailImageUrl: String let thumbnailImageUrl: String

View File

@@ -1,55 +0,0 @@
//
// ContentMainNewContentThemeView.swift
// SodaLive
//
// Created by klaus on 2023/08/11.
//
import SwiftUI
struct ContentMainNewContentThemeView: View {
let themes: [String]
let selectTheme: (String) -> Void
@Binding var selectedTheme: String
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(0..<themes.count, id: \.self) { index in
let theme = themes[index]
Text(theme)
.appFont(size: 14.7, weight: .medium)
.foregroundColor(Color(hex: selectedTheme == theme ? "3bb9f1" : "777777"))
.padding(.horizontal, 13.3)
.padding(.vertical, 9.3)
.border(
Color(hex: selectedTheme == theme ? "3bb9f1" : "eeeeee"),
width: 0.5
)
.cornerRadius(16.7)
.overlay(
RoundedRectangle(cornerRadius: CGFloat(16.7))
.stroke(lineWidth: 0.5)
.foregroundColor(Color(hex: selectedTheme == theme ? "3bb9f1" : "eeeeee"))
)
.onTapGesture {
if selectedTheme != theme {
selectTheme(theme)
}
}
}
}
}
}
}
struct ContentMainNewContentThemeView_Previews: PreviewProvider {
static var previews: some View {
ContentMainNewContentThemeView(
themes: ["전체", "테마1", "테마2"],
selectTheme: { _ in },
selectedTheme: .constant("전체")
)
}
}

View File

@@ -1,68 +0,0 @@
//
// ContentMainNewContentView.swift
// SodaLive
//
// Created by klaus on 2023/08/11.
//
import SwiftUI
struct ContentMainNewContentView: View {
@StateObject private var viewModel = ContentMainNewContentViewModel()
var body: some View {
LazyVStack(spacing: 16.7) {
HStack(spacing: 0) {
Text("새로운 콘텐츠")
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "eeeeee"))
Spacer()
Image("ic_forward")
.resizable()
.frame(width: 20, height: 20)
.onTapGesture {
AppState.shared.setAppStep(step: .newContentAll(isFree: false))
}
}
if !viewModel.themeList.isEmpty {
ContentMainNewContentThemeView(
themes: viewModel.themeList,
selectTheme: { theme in
viewModel.selectedTheme = theme
},
selectedTheme: $viewModel.selectedTheme
)
}
if !viewModel.newContentList.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 13.3) {
ForEach(0..<viewModel.newContentList.count, id: \.self) {
ContentMainItemView(item: viewModel.newContentList[$0])
}
}
}
}
if viewModel.isLoading {
ActivityIndicatorView()
.frame(width: 100, height: 100)
}
}
.frame(maxWidth: .infinity)
.onAppear {
viewModel.getThemeList()
viewModel.getNewContentOfTheme()
}
}
}
struct ContentMainNewContentView_Previews: PreviewProvider {
static var previews: some View {
ContentMainNewContentView()
}
}

View File

@@ -1,105 +0,0 @@
//
// ContentMainNewContentViewModel.swift
// SodaLive
//
// Created by klaus on 2023/12/11.
//
import Foundation
import Combine
final class ContentMainNewContentViewModel: ObservableObject {
private let repository = ContentRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var themeList = [String]()
@Published var newContentList = [GetAudioContentMainItem]()
@Published var selectedTheme = "전체" {
didSet {
newContentList.removeAll()
getNewContentOfTheme()
}
}
func getThemeList() {
repository.getNewContentThemeList()
.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<[String]>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.themeList.removeAll()
self.themeList.append("전체")
self.themeList.append(contentsOf: data)
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
}
}
.store(in: &subscription)
}
func getNewContentOfTheme() {
isLoading = true
repository.getNewContentOfTheme(theme: selectedTheme == "전체" ? "" : selectedTheme)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
self.isLoading = false
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<[GetAudioContentMainItem]>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.newContentList.removeAll()
self.newContentList.append(contentsOf: data)
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
}
}
.store(in: &subscription)
}
}

View File

@@ -1,61 +0,0 @@
//
// ContentMainMyStashView.swift
// SodaLive
//
// Created by klaus on 2023/08/11.
//
import SwiftUI
struct ContentMainMyStashView: View {
@StateObject private var viewModel = ContentMainMyStashViewModel()
var body: some View {
ZStack {
if !viewModel.orderList.isEmpty {
VStack(alignment: .leading, spacing: 13.3) {
HStack(spacing: 0) {
Text("내 보관함")
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "eeeeee"))
Spacer()
Text("전체보기")
.appFont(size: 11.3, weight: .light)
.foregroundColor(Color(hex: "bbbbbb"))
.onTapGesture {
AppState.shared.setAppStep(step: .orderListAll)
}
}
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 13.3) {
ForEach(0..<viewModel.orderList.count, id: \.self) { index in
let item = viewModel.orderList[index]
ContentMainItemView(item: item)
}
}
}
}
.padding(.bottom, 40)
}
if viewModel.isLoading {
ActivityIndicatorView()
.frame(width: 100, height: 100)
}
}
.frame(maxWidth: .infinity)
.onAppear {
viewModel.getOrderList()
}
}
}
struct ContentMainMyStashView_Previews: PreviewProvider {
static var previews: some View {
ContentMainMyStashView()
}
}

View File

@@ -1,62 +0,0 @@
//
// ContentMainMyStashViewModel.swift
// SodaLive
//
// Created by klaus on 2023/12/11.
//
import Foundation
import Combine
final class ContentMainMyStashViewModel: ObservableObject {
private let repository = ContentRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var orderList = [GetAudioContentMainItem]()
func getOrderList() {
isLoading = true
repository.getMainOrderList()
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
self.isLoading = false
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<[GetAudioContentMainItem]>.self, from: responseData)
self.isLoading = false
if let data = decoded.data, decoded.success {
self.orderList.removeAll()
self.orderList.append(contentsOf: 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

@@ -1,150 +0,0 @@
//
// ContentMainCreatorRankingView.swift
// SodaLive
//
// Created by klaus on 1/5/25.
//
import SwiftUI
import Kingfisher
struct ContentMainCreatorRankingView: View {
@StateObject var viewModel = ContentMainCreatorRankingViewModel()
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 {
ZStack {
if let response = viewModel.creatorRankingResponse {
VStack(alignment: .leading, spacing: 13.3) {
VStack(alignment: .leading, spacing: 4) {
if let coloredTitle = response.coloredTitle, let color = response.color {
let titleArray = response.title.components(separatedBy: coloredTitle)
HStack(spacing: 0) {
Text(titleArray[0])
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.grayee)
Text(coloredTitle)
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: color))
if titleArray.count > 1 {
Text(titleArray[1])
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.grayee)
}
}
} else {
Text(response.title)
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.grayee)
}
if let desc = response.desc {
VStack(spacing: 8) {
Text("\(desc)")
.appFont(size: 14.7, weight: .bold)
.foregroundColor(Color.grayee)
Text("※ 인기 크리에이터의 순위는 매주 업데이트됩니다.")
.appFont(size: 13.3, weight: .light)
.foregroundColor(Color.graybb)
}
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(Color.gray22)
.padding(.top, 13.3)
}
}
.frame(maxWidth: .infinity)
.frame(alignment: .leading)
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)
.appFont(size: 11.3, weight: .medium)
.foregroundColor(Color.grayee)
.lineLimit(1)
.frame(width: 93.3)
.padding(.top, 13.3)
Text(creator.tags)
.appFont(size: 10, weight: .medium)
.foregroundColor(Color.button)
.lineLimit(1)
.frame(width: 93.3)
.padding(.top, 3.3)
}
.contentShape(Rectangle())
.onTapGesture {
AppState.shared
.setAppStep(step: .creatorDetail(userId: creator.id))
}
}
}
}
.frame(maxWidth: .infinity)
.frame(alignment: .leading)
}
} else {
EmptyView()
.frame(width: 0, height: 0)
}
}
.onAppear {
viewModel.getCreatorRanking()
}
}
}
#Preview {
ContentMainCreatorRankingView()
}

View File

@@ -1,54 +0,0 @@
//
// ContentMainCreatorRankingViewModel.swift
// SodaLive
//
// Created by klaus on 1/5/25.
//
import Foundation
import Combine
final class ContentMainCreatorRankingViewModel: ObservableObject {
private let repository = ContentRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var creatorRankingResponse: GetExplorerSectionResponse? = nil
func getCreatorRanking() {
repository.getCreatorRanking()
.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<GetExplorerSectionResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.creatorRankingResponse = data
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "인기 크리에이터를 불러오지 못했습니다. 다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "인기 크리에이터를 불러오지 못했습니다. 다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
}
}
.store(in: &subscription)
}
}

View File

@@ -1,114 +0,0 @@
//
// ContentMainRankingView.swift
// SodaLive
//
// Created by klaus on 2023/10/15.
//
import SwiftUI
import Kingfisher
struct ContentMainRankingView: View {
@StateObject private var viewModel = ContentMainRankingViewModel()
let rows = [
GridItem(.fixed(60), alignment: .leading),
GridItem(.fixed(60), alignment: .leading),
GridItem(.fixed(60), alignment: .leading)
]
var body: some View {
LazyVStack(spacing: 16.7) {
HStack(spacing: 0) {
Text("인기 콘텐츠")
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "eeeeee"))
Spacer()
Image("ic_forward")
.onTapGesture {
AppState.shared.setAppStep(step: .contentRankingAll)
}
}
VStack(spacing: 8) {
Text("\(viewModel.dateString)")
.appFont(size: 14.7, weight: .bold)
.foregroundColor(Color(hex: "eeeeee"))
Text("※ 인기 콘텐츠의 순위는 매주 업데이트됩니다.")
.appFont(size: 13.3, weight: .light)
.foregroundColor(Color(hex: "bbbbbb"))
}
.padding(.vertical, 8)
.frame(width: screenSize().width - 26.7)
.background(Color(hex: "222222"))
if !viewModel.contentRankingSortList.isEmpty {
ContentMainRankingSortView(
sorts: viewModel.contentRankingSortList,
selectSort: { viewModel.selectedContentRankingSort = $0 },
selectedSort: $viewModel.selectedContentRankingSort
)
}
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: rows, spacing: 13.3) {
ForEach(0..<viewModel.contentRankingItemList.count, id: \.self) { index in
let content = viewModel.contentRankingItemList[index]
HStack(spacing: 0) {
KFImage(URL(string: content.coverImageUrl))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: 60,
height: 60
)
)
.resizable()
.frame(width: 60, height: 60)
.cornerRadius(2.7)
Text("\(index + 1)")
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color(hex: "3bb9f1"))
.padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 8) {
Text(content.title)
.lineLimit(2)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "d2d2d2"))
Text(content.creatorNickname)
.appFont(size: 11, weight: .medium)
.foregroundColor(Color(hex: "777777"))
}
}
.frame(maxWidth: screenSize().width * 0.66, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
AppState
.shared
.setAppStep(step: .contentDetail(contentId: content.contentId))
}
}
}
.frame(height: 207)
}
}
.frame(maxWidth: .infinity)
.onAppear {
viewModel.getContentRankingSortType()
viewModel.getContentRanking()
}
}
}
struct ContentMainRankingView_Previews: PreviewProvider {
static var previews: some View {
ContentMainRankingView()
}
}

View File

@@ -1,105 +0,0 @@
//
// ContentMainRankingViewModel.swift
// SodaLive
//
// Created by klaus on 2023/12/11.
//
import Foundation
import Combine
final class ContentMainRankingViewModel: ObservableObject {
private let repository = ContentRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var dateString = ""
@Published var contentRankingSortList = [String]()
@Published var contentRankingItemList = [GetAudioContentRankingItem]()
@Published var selectedContentRankingSort = "매출" {
didSet {
getContentRanking()
}
}
func getContentRankingSortType() {
repository.getContentRankingSortType()
.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<[String]>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.contentRankingSortList.removeAll()
self.contentRankingSortList.append(contentsOf: data)
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
}
}
.store(in: &subscription)
}
func getContentRanking() {
isLoading = true
repository.getContentRanking(page: 1, size: 12, sortType: selectedContentRankingSort)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
self.isLoading = false
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 {
dateString = "\(data.startDate)~\(data.endDate)"
self.contentRankingItemList.removeAll()
self.contentRankingItemList.append(contentsOf: 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
}
}
.store(in: &subscription)
}
}

View File

@@ -1,63 +0,0 @@
//
// ContentMainRecommendSeriesView.swift
// SodaLive
//
// Created by klaus on 5/7/24.
//
import SwiftUI
struct ContentMainRecommendSeriesView: View {
@StateObject private var viewModel = ContentMainRecommendSeriesViewModel()
var body: some View {
ZStack {
if !viewModel.seriesList.isEmpty {
VStack(alignment: .leading, spacing: 13.3) {
Text("추천 시리즈")
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.grayee)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 13.3) {
ForEach(0..<viewModel.seriesList.count, id: \.self) {
let item = viewModel.seriesList[$0]
SeriesListBigItemView(item: item, isVisibleCreator: true)
}
}
}
HStack(spacing: 8) {
Image("ic_refresh")
Text("새로고침")
.appFont(size: 14.7, weight: .medium)
.foregroundColor(Color.grayd2)
}
.padding(.vertical, 11)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.overlay(
RoundedRectangle(cornerRadius: 26.7)
.stroke(Color.gray90, lineWidth: 1)
)
.onTapGesture {
viewModel.getRecommendSeriesList()
}
}
.padding(.bottom, 26.7)
.padding(.horizontal, 13.3)
} else {
EmptyView()
}
}
.onAppear {
viewModel.getRecommendSeriesList()
}
}
}
#Preview {
ContentMainRecommendSeriesView()
}

View File

@@ -1,60 +0,0 @@
//
// ContentMainRecommendSeriesViewModel.swift
// SodaLive
//
// Created by klaus on 5/7/24.
//
import Foundation
import Combine
final class ContentMainRecommendSeriesViewModel: ObservableObject {
private let repository = SeriesRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var seriesList = [SeriesListItem]()
func getRecommendSeriesList() {
repository.getRecommendSeriesList()
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
self.isLoading = false
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<[SeriesListItem]>.self, from: responseData)
self.isLoading = false
if let data = decoded.data, decoded.success {
self.seriesList.removeAll()
self.seriesList.append(contentsOf: 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

@@ -1,98 +0,0 @@
//
// ContentMainAlarmAllView.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import SwiftUI
struct ContentMainAlarmAllView: View {
@StateObject var viewModel = ContentMainAlarmAllViewModel()
@State private var isInitialized = false
var body: some View {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(alignment: .leading, spacing: 13.3) {
DetailNavigationBar(title: "새로운 알람")
Text("※ 최근 2주간 등록된 새로운 알람 입니다.")
.appFont(size: 14.7, weight: .medium)
.foregroundColor(.graybb)
.padding(.horizontal, 13.3)
.padding(.vertical, 8)
.frame(width: screenSize().width, alignment: .leading)
.background(Color.gray22)
ContentMainNewContentThemeView(
themes: viewModel.themeList,
selectTheme: {
viewModel.selectedTheme = $0
},
selectedTheme: $viewModel.selectedTheme
)
.padding(.horizontal, 20)
HStack(spacing: 0) {
Text("전체")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2"))
Text("\(viewModel.totalCount)")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "ff5c49"))
.padding(.leading, 8)
Text("")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2"))
.padding(.leading, 2)
}
.padding(.horizontal, 13.3)
ScrollView(.vertical, showsIndicators: false) {
let horizontalPadding: CGFloat = 16
let gridSpacing: CGFloat = 16
let itemSize = (screenSize().width - (horizontalPadding * 2) - gridSpacing) / 2
LazyVGrid(
columns: Array(
repeating: GridItem(
.flexible(),
spacing: gridSpacing,
alignment: .topLeading
),
count: 2
),
alignment: .leading,
spacing: gridSpacing
) {
ForEach(0..<viewModel.newContentList.count, id: \.self) { index in
ContentNewAllItemView(width: itemSize, item: viewModel.newContentList[index])
.onAppear {
if index == viewModel.newContentList.count - 1 {
viewModel.getContentMainAlarmAll()
}
}
}
}
.padding(.horizontal, 13.3)
}
}
.onAppear {
if !isInitialized {
viewModel.getContentMainAlarmAll()
isInitialized = true
}
}
}
.navigationBarHidden(true)
}
}
}
#Preview {
ContentMainAlarmAllView()
}

View File

@@ -1,94 +0,0 @@
//
// ContentMainAlarmAllViewModel.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import Foundation
import Combine
final class ContentMainAlarmAllViewModel: ObservableObject {
private let repository = ContentMainTabAlarmRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var themeList = ["전체", "모닝콜", "슬립콜", "알람"]
@Published var newContentList = [GetAudioContentMainItem]()
@Published var selectedTheme = "전체" {
didSet {
page = 1
isLast = false
getContentMainAlarmAll()
}
}
@Published var totalCount = 0
var page = 1
var isLast = false
private let pageSize = 20
func getContentMainAlarmAll() {
if (!isLast && !isLoading) {
isLoading = true
repository.getContentMainAlarmAll(
theme: selectedTheme == "전체" ? "" : selectedTheme,
page: page,
size: pageSize
)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
self.isLoading = false
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<GetNewContentAllResponse>.self, from: responseData)
self.isLoading = false
if let data = decoded.data, decoded.success {
if page == 1 {
newContentList.removeAll()
}
self.totalCount = data.totalCount
if !data.items.isEmpty {
page += 1
self.newContentList.append(contentsOf: data.items)
} else {
isLast = true
}
} 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)
} else {
isLoading = false
}
}
}

View File

@@ -1,37 +0,0 @@
//
// ContentMainTabAlarmRepository.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import Foundation
import CombineMoya
import Combine
import Moya
final class ContentMainTabAlarmRepository {
private let api = MoyaProvider<ContentApi>()
func getContentMainAlarm() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getContentMainAlarm(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
func getContentMainAlarmAll(theme: String, page: Int = 1, size: Int = 10) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getContentMainAlarmAll(
theme: theme,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size
)
)
}
}

View File

@@ -1,59 +0,0 @@
//
// ContentMainTabAlarmView.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import SwiftUI
struct ContentMainTabAlarmView: View {
@StateObject var viewModel = ContentMainTabAlarmViewModel()
var body: some View {
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.alarmThemeList.isEmpty {
ContentMainNewContentViewV2(
title: "새로운 알람",
onClickMore: {
AppState.shared
.setAppStep(step: .newAlarmContentAll)
},
themeList: viewModel.alarmThemeList,
contentList: viewModel.newAlarmContentList
) {
viewModel.getContentMainAlarm(theme: $0)
}
.padding(.top, 30)
}
if !viewModel.eventBannerList.isEmpty {
SectionEventBannerView(items: viewModel.eventBannerList)
.padding(.top, 30)
}
if !viewModel.curationList.isEmpty {
ContentMainCurationViewV2(curationList: viewModel.curationList)
.padding(.top, 30)
}
}
.onAppear {
viewModel.fetchData()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
#Preview {
ContentMainTabAlarmView()
}

View File

@@ -1,106 +0,0 @@
//
// ContentMainTabAlarmViewModel.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import Foundation
import Combine
final class ContentMainTabAlarmViewModel: ObservableObject {
private let repository = ContentMainTabAlarmRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var bannerList: [GetAudioContentBannerResponse] = []
@Published var alarmThemeList: [String] = []
@Published var newAlarmContentList: [GetAudioContentMainItem] = []
@Published var rankAlarmContentList: [GetAudioContentRankingItem] = []
@Published var eventBannerList: [EventItem] = []
@Published var curationList: [GetContentCurationResponse] = []
func fetchData() {
isLoading = true
repository.getContentMainAlarm()
.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<GetContentMainTabAlarmResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.bannerList = data.contentBannerList
self.alarmThemeList = ["전체"] + data.alarmThemeList
self.newAlarmContentList = data.newAlarmContentList
self.rankAlarmContentList = data.rankAlarmContentList
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 getContentMainAlarm(theme: String) {
isLoading = true
repository.getContentMainAlarmAll(theme: theme)
.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<GetNewContentAllResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.newAlarmContentList = 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)
}
}

View File

@@ -1,17 +0,0 @@
//
// GetContentMainTabAlarmResponse.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import Foundation
struct GetContentMainTabAlarmResponse: Decodable {
let contentBannerList: [GetAudioContentBannerResponse]
let alarmThemeList: [String]
let newAlarmContentList: [GetAudioContentMainItem]
let rankAlarmContentList: [GetAudioContentRankingItem]
let eventBannerList: GetEventResponse
let curationList: [GetContentCurationResponse]
}

View File

@@ -1,93 +0,0 @@
//
// ContentMainAsmrAllView.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import SwiftUI
struct ContentMainAsmrAllView: View {
@StateObject var viewModel = ContentNewAllViewModel()
@State private var isInitialized = false
var body: some View {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(alignment: .leading, spacing: 13.3) {
DetailNavigationBar(title: "새로운 ASMR")
Text("※ 최근 2주간 등록된 새로운 ASMR 입니다.")
.appFont(size: 14.7, weight: .medium)
.foregroundColor(.graybb)
.padding(.horizontal, 13.3)
.padding(.vertical, 8)
.frame(width: screenSize().width, alignment: .leading)
.background(Color.gray22)
HStack(spacing: 0) {
Text("전체")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2"))
Text("\(viewModel.totalCount)")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "ff5c49"))
.padding(.leading, 8)
Text("")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2"))
.padding(.leading, 2)
}
.padding(.horizontal, 13.3)
ScrollView(.vertical, showsIndicators: false) {
let horizontalPadding: CGFloat = 16
let gridSpacing: CGFloat = 16
let itemSize = (screenSize().width - (horizontalPadding * 2) - gridSpacing) / 2
LazyVGrid(
columns: Array(
repeating: GridItem(
.flexible(),
spacing: gridSpacing,
alignment: .topLeading
),
count: 2
),
alignment: .leading,
spacing: gridSpacing
) {
ForEach(0..<viewModel.newContentList.count, id: \.self) { index in
ContentNewAllItemView(width: itemSize, item: viewModel.newContentList[index])
.onAppear {
if index == viewModel.newContentList.count - 1 {
viewModel.getNewContentList()
}
}
}
}
.padding(.horizontal, 13.3)
}
}
.onAppear {
if !isInitialized {
if viewModel.selectedTheme != "ASMR" {
viewModel.selectedTheme = "ASMR"
} else if viewModel.newContentList.isEmpty {
viewModel.getNewContentList()
}
isInitialized = true
}
}
}
.navigationBarHidden(true)
}
}
}
#Preview {
ContentMainAsmrAllView()
}

View File

@@ -1,35 +0,0 @@
//
// ContentMainTabAsmrRepository.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import Foundation
import CombineMoya
import Combine
import Moya
final class ContentMainTabAsmrRepository {
private let api = MoyaProvider<ContentApi>()
func getContentMainAsmr() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getContentMainAsmr(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
func getPopularContentByCreator(creatorId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getPopularAsmrContentByCreator(
creatorId: creatorId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
}

View File

@@ -1,69 +0,0 @@
//
// ContentMainTabAsmrView.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import SwiftUI
struct ContentMainTabAsmrView: View {
@StateObject var viewModel = ContentMainTabAsmrViewModel()
var body: some View {
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.newAsmrContentList.isEmpty {
ContentMainNewContentViewV2(
title: "새로운 ASMR",
onClickMore: {
AppState.shared
.setAppStep(step: .newAsmrContentAll)
},
themeList: [],
contentList: viewModel.newAsmrContentList
) { _ in }
.padding(.top, 30)
}
if !viewModel.creatorList.isEmpty {
ContentByChannelView(
title: "채널별 추천 ASMR",
creatorList: viewModel.creatorList,
contentList: viewModel.salesCountRankContentList,
onClickCreator: {
viewModel.getPopularContentByCreator(creatorId: $0)
}
)
.padding(.top, 30)
}
if !viewModel.eventBannerList.isEmpty {
SectionEventBannerView(items: viewModel.eventBannerList)
.padding(.top, 30)
}
if !viewModel.curationList.isEmpty {
ContentMainCurationViewV2(curationList: viewModel.curationList)
.padding(.top, 30)
}
}
.onAppear {
viewModel.fetchData()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
#Preview {
ContentMainTabAsmrView()
}

View File

@@ -1,106 +0,0 @@
//
// ContentMainTabAsmrViewModel.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import Foundation
import Combine
final class ContentMainTabAsmrViewModel: ObservableObject {
private let repository = ContentMainTabAsmrRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var bannerList: [GetAudioContentBannerResponse] = []
@Published var newAsmrContentList: [GetAudioContentMainItem] = []
@Published var creatorList: [ContentCreatorResponse] = []
@Published var salesCountRankContentList: [GetAudioContentRankingItem] = []
@Published var eventBannerList: [EventItem] = []
@Published var curationList: [GetContentCurationResponse] = []
func fetchData() {
isLoading = true
repository.getContentMainAsmr()
.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<GetContentMainTabAsmrResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.bannerList = data.contentBannerList
self.newAsmrContentList = data.newAsmrContentList
self.creatorList = data.creatorList
self.salesCountRankContentList = data.salesCountRankContentList
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 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

@@ -1,15 +0,0 @@
//
// GetContentMainTabAsmrResponse.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
struct GetContentMainTabAsmrResponse: Decodable {
let contentBannerList: [GetAudioContentBannerResponse]
let newAsmrContentList: [GetAudioContentMainItem]
let creatorList: [ContentCreatorResponse]
let salesCountRankContentList: [GetAudioContentRankingItem]
let eventBannerList: GetEventResponse
let curationList: [GetContentCurationResponse]
}

View File

@@ -1,64 +0,0 @@
//
// ContentMainTabContentRepository.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import Foundation
import CombineMoya
import Combine
import Moya
final class ContentMainTabContentRepository {
private let api = MoyaProvider<ContentApi>()
func getContentMainContent() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getContentMainContent(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
func getNewContentOfTheme(theme: String) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getContentMainNewContentOfTheme(
theme: theme,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
func getContentRanking(sortType: String) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getDailyContentRanking(
sortType: sortType,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
func getRecommendContentByTag(tag: String) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getRecommendContentByTag(
tag: tag,
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
func getPopularContentByCreator(creatorId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getContentMainContentPopularContentByCreator(
creatorId: creatorId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
}

View File

@@ -1,93 +0,0 @@
//
// ContentMainTabContentView.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
struct ContentMainTabContentView: View {
@StateObject var viewModel = ContentMainTabContentViewModel()
var body: some View {
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.contentThemeList.isEmpty {
ContentMainNewContentViewV2(
title: "새로운 단편",
onClickMore: {
AppState.shared
.setAppStep(step: .newContentAll(isFree: false))
},
themeList: viewModel.contentThemeList,
contentList: viewModel.newContentList
) {
viewModel.getNewContentOfTheme(theme: $0)
}
.padding(.top, 30)
}
if !viewModel.rankSortTypeList.isEmpty {
ContentMainTabRankContentView(
title: "일간 랭킹",
isMore: false,
onClickMore: {},
sortList: viewModel.rankSortTypeList,
onClickSort: { viewModel.getContentRanking(sort: $0) },
contentList: viewModel.rankContentList
)
.padding(.top, 30)
}
if !viewModel.contentRankCreatorList.isEmpty {
ContentByChannelView(
title: "채널별 추천 단편",
creatorList: viewModel.contentRankCreatorList,
contentList: viewModel.likeCountRankContentList,
onClickCreator: {
viewModel.getPopularContentByCreator(creatorId: $0)
}
)
.padding(.top, 30)
}
if !viewModel.eventBannerList.isEmpty {
SectionEventBannerView(items: viewModel.eventBannerList)
.padding(.top, 30)
}
if !viewModel.tagList.isEmpty {
ContentMainTagCurationView(
tagList: viewModel.tagList,
contentList: viewModel.tagCurationContentList
) {
viewModel.getRecommendContentByTag(tag: $0)
}
.padding(.top, 30)
}
if !viewModel.curationList.isEmpty {
ContentMainCurationViewV2(curationList: viewModel.curationList)
.padding(.top, 30)
}
}
.onAppear {
viewModel.fetchData()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
#Preview {
ContentMainTabContentView()
}

View File

@@ -1,231 +0,0 @@
//
// ContentMainTabContentViewModel.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import Foundation
import Combine
final class ContentMainTabContentViewModel: ObservableObject {
private let repository = ContentMainTabContentRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var bannerList: [GetAudioContentBannerResponse] = []
@Published var contentThemeList: [String] = []
@Published var newContentList: [GetAudioContentMainItem] = []
@Published var rankSortTypeList: [String] = []
@Published var rankContentList: [GetAudioContentRankingItem] = []
@Published var contentRankCreatorList: [ContentCreatorResponse] = []
@Published var likeCountRankContentList: [GetAudioContentRankingItem] = []
@Published var eventBannerList: [EventItem] = []
@Published var tagList: [String] = []
@Published var tagCurationContentList: [GetAudioContentMainItem] = []
@Published var curationList: [GetContentCurationResponse] = []
func fetchData() {
isLoading = true
repository.getContentMainContent()
.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<GetContentMainTabContentResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.bannerList = data.bannerList
self.contentThemeList = ["전체"] + data.contentThemeList
self.newContentList = data.newContentList
self.rankSortTypeList = data.rankSortTypeList
self.rankContentList = data.rankContentList
self.contentRankCreatorList = data.contentRankCreatorList
self.likeCountRankContentList = data.likeCountRankContentList
self.eventBannerList = data.eventBannerList.eventList
self.tagList = data.tagList
self.tagCurationContentList = data.tagCurationContentList
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 getNewContentOfTheme(theme: String) {
isLoading = true
repository.getNewContentOfTheme(theme: theme == "전체" ? "" : theme)
.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<[GetAudioContentMainItem]>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.newContentList = 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 getContentRanking(sort: String = "매출") {
isLoading = true
repository.getContentRanking(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<[GetAudioContentRankingItem]>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.rankContentList = 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 getRecommendContentByTag(tag: String) {
isLoading = true
repository.getRecommendContentByTag(tag: tag)
.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<[GetAudioContentMainItem]>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.tagCurationContentList = 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 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.likeCountRankContentList = 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

@@ -1,165 +0,0 @@
//
// ContentMainTabRankContentView.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import SwiftUI
import Kingfisher
struct ContentMainTabRankContentView: View {
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
let rows = [
GridItem(.fixed(60), alignment: .leading),
GridItem(.fixed(60), alignment: .leading),
GridItem(.fixed(60), alignment: .leading)
]
let title: String
let isMore: Bool
let onClickMore: () -> Void
let sortList: [String]
let onClickSort: (String) -> Void
let contentList: [GetAudioContentRankingItem]
@State private var selectedSort = ""
var body: some View {
VStack(spacing: 13.3) {
HStack(spacing: 0) {
Text(title)
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "eeeeee"))
Spacer()
if isMore {
Image("ic_forward")
.onTapGesture { onClickMore() }
}
}
.padding(.horizontal, 13.3)
if !sortList.isEmpty {
ContentMainRankingSortView(
sorts: sortList,
selectSort: {
selectedSort = $0
onClickSort($0)
},
selectedSort: $selectedSort
)
}
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: rows, spacing: 13.3) {
ForEach(0..<contentList.count, id: \.self) { index in
let content = contentList[index]
HStack(spacing: 0) {
KFImage(URL(string: content.coverImageUrl))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: 60,
height: 60
)
)
.resizable()
.frame(width: 60, height: 60)
.cornerRadius(2.7)
Text("\(index + 1)")
.appFont(size: 16.7, weight: .bold)
.foregroundColor(.button)
.padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 8) {
Text(content.title)
.lineLimit(2)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(.grayd2)
Text(content.creatorNickname)
.appFont(size: 11, weight: .medium)
.foregroundColor(.gray77)
}
}
.frame(width: screenSize().width * 0.66, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
AppState.shared
.setAppStep(step: .contentDetail(contentId: content.contentId))
} else {
AppState.shared
.setAppStep(step: .login)
}
}
}
}
.padding(.horizontal, 13.3)
.frame(height: 207)
}
}
.onAppear {
if !sortList.isEmpty {
selectedSort = sortList[0]
}
}
}
}
#Preview {
ContentMainTabRankContentView(
title: "인기 단편",
isMore: true,
onClickMore: {},
sortList: ["매출", "댓글", "좋아요"],
onClickSort: { _ in },
contentList: [
GetAudioContentRankingItem(
contentId: 1,
title: "안녕하세요 오늘은 커버곡을 들려드릴께요....안녕하세요 오늘은 커버곡을 들려드릴께요....",
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
themeStr: "커버곡",
price: 100,
duration: "00:30:20",
creatorId: 1,
creatorNickname: "유저1",
isPointAvailable: false,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
),
GetAudioContentRankingItem(
contentId: 2,
title: "안녕하세요 오늘은 커버곡을 들려드릴께요....",
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
themeStr: "커버곡",
price: 0,
duration: "00:30:20",
creatorId: 2,
creatorNickname: "유저2",
isPointAvailable: false,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
),
GetAudioContentRankingItem(
contentId: 3,
title: "안녕하세요 오늘은 커버곡을 들려드릴께요....안녕하세요 오늘은 커버곡을 들려드릴께요....",
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
themeStr: "커버곡",
price: 50,
duration: "00:30:20",
creatorId: 3,
creatorNickname: "유저3",
isPointAvailable: true,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
)
]
)
}

View File

@@ -1,245 +0,0 @@
//
// ContentMainTagCurationView.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import SwiftUI
import Kingfisher
struct ContentMainTagCurationView: View {
let tagList: [String]
let contentList: [GetAudioContentMainItem]
let selectTag: (String) -> Void
let tagColumns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
let contentColumns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
@State private var selectedTag = ""
var body: some View {
VStack(alignment: .leading, spacing: 13.3) {
Text("태그별 추천 콘텐츠")
.appFont(size: 18.3, weight: .bold)
.foregroundColor(.grayee)
.padding(.horizontal, 13.3)
LazyVGrid(columns: tagColumns, spacing: 6) {
ForEach(0..<tagList.count, id: \.self) { index in
let tag = tagList[index]
Text(tagList[index])
.appFont(size: 10, weight: .medium)
.foregroundColor(
selectedTag == tag ?
.button:
.gray77
)
.padding(.vertical, 10)
.frame(width: (screenSize().width - 18 - 26.7) / 4)
.overlay(
RoundedRectangle(cornerRadius: 2.6)
.strokeBorder(lineWidth: 1)
.foregroundColor(
selectedTag == tag ?
.button:
.gray77
)
)
.onTapGesture {
if selectedTag != tag {
selectedTag = tag
selectTag(tag)
}
}
}
}
.padding(.horizontal, 13.3)
LazyVGrid(columns: contentColumns, spacing: 13.3) {
ForEach(0..<contentList.count, id: \.self) { index in
ContentMainTagCurationContentView(
item: contentList[index],
itemWidth: (screenSize().width - 40) / 3
)
}
}
.padding(.horizontal, 13.3)
}
.onAppear {
selectedTag = tagList[0]
}
}
}
struct ContentMainTagCurationContentView: View {
let item: GetAudioContentMainItem
let itemWidth: CGFloat
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .bottom) {
ZStack(alignment: .topTrailing) {
KFImage(URL(string: item.coverImageUrl))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: itemWidth,
height: itemWidth
)
)
.resizable()
.scaledToFill()
.frame(width: itemWidth, height: itemWidth, alignment: .top)
.cornerRadius(2.7)
if item.isPointAvailable {
Image("ic_point")
.resizable()
.frame(width: 20, height: 20)
.padding(.top, 2.7)
.padding(.trailing, 2.7)
}
}
VStack(spacing: 0) {
Spacer()
HStack(spacing: 0) {
HStack(spacing: 2) {
if item.price > 0 {
Image("ic_card_can_gray")
Text("\(item.price)")
.appFont(size: 8.5, weight: .medium)
.foregroundColor(Color.white)
} else {
Text("무료")
.appFont(size: 8.5, weight: .medium)
.foregroundColor(Color.white)
}
}
.padding(3)
.background(Color.gray33.opacity(0.7))
.cornerRadius(10)
.padding(.leading, 2.7)
.padding(.bottom, 2.7)
Spacer()
HStack(spacing: 2) {
Text(item.duration)
.appFont(size: 8.5, weight: .medium)
.foregroundColor(Color.white)
}
.padding(3)
.background(Color.gray33.opacity(0.7))
.cornerRadius(10)
.padding(.trailing, 2.7)
.padding(.bottom, 2.7)
}
}
}
.frame(width: itemWidth, height: itemWidth)
Text(item.title)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayd2)
.frame(width: itemWidth, alignment: .leading)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
HStack(spacing: 5.3) {
KFImage(URL(string: item.creatorProfileImageUrl))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: 21.3,
height: 21.3
)
)
.resizable()
.scaledToFill()
.frame(width: 21.3, height: 21.3)
.clipShape(Circle())
.onTapGesture { AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId)) }
Text(item.creatorNickname)
.appFont(size: 12, weight: .medium)
.foregroundColor(.gray77)
.lineLimit(1)
}
.padding(.bottom, 10)
}
.onTapGesture {
AppState.shared
.setAppStep(step: .contentDetail(contentId: item.contentId))
}
}
}
#Preview {
ContentMainTagCurationView(
tagList: ["test", "test2", "test3", "test4", "test5", "test6", "test7"],
contentList: [
GetAudioContentMainItem(
contentId: 1,
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
title: "ㅓ처랴햐햫햐햐",
creatorId: 8,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
creatorNickname: "유저1",
price: 100,
duration: "00:00:30",
isPointAvailable: true
),
GetAudioContentMainItem(
contentId: 2,
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
title: "ㅓ처랴햐햫햐햐",
creatorId: 8,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
creatorNickname: "유저2",
price: 0,
duration: "00:00:30",
isPointAvailable: false
),
GetAudioContentMainItem(
contentId: 3,
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
title: "ㅓ처랴햐햫햐햐",
creatorId: 8,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
creatorNickname: "유저3",
price: 1000,
duration: "00:00:30",
isPointAvailable: false
),
GetAudioContentMainItem(
contentId: 4,
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
title: "ㅓ처랴햐햫햐햐",
creatorId: 8,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
creatorNickname: "유저3",
price: 50000,
duration: "00:00:30",
isPointAvailable: false
)
],
selectTag: { _ in }
)
}

View File

@@ -1,20 +0,0 @@
//
// GetContentMainTabContentResponse.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
struct GetContentMainTabContentResponse: Decodable {
let bannerList: [GetAudioContentBannerResponse]
let contentThemeList: [String]
let newContentList: [GetAudioContentMainItem]
let rankSortTypeList: [String]
let rankContentList: [GetAudioContentRankingItem]
let contentRankCreatorList: [ContentCreatorResponse]
let likeCountRankContentList: [GetAudioContentRankingItem]
let eventBannerList: GetEventResponse
let tagList: [String]
let tagCurationContentList: [GetAudioContentMainItem]
let curationList: [GetContentCurationResponse]
}

View File

@@ -1,220 +0,0 @@
//
// ContentByChannelView.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import SwiftUI
import Kingfisher
struct ContentByChannelView: View {
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
let title: String
let creatorList: [ContentCreatorResponse]
let contentList: [GetAudioContentRankingItem]
let onClickCreator: (Int) -> Void
@State private var selectedCreatorId = 0
let columns = [
GridItem(.flexible(), spacing: 13.3),
GridItem(.flexible(), spacing: 13.3)
]
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(title)
.appFont(size: 18.3, weight: .bold)
.foregroundColor(.grayee)
.padding(.horizontal, 13.3)
ScrollView(.horizontal, showsIndicators: false) {
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
selectedCreatorId = creatorId
onClickCreator(creatorId)
}
}
}
.padding(.horizontal, 13.3)
}
LazyVGrid(columns: columns, spacing: 13.3) {
ForEach(0..<contentList.count, id: \.self) { index in
let content = contentList[index]
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment:.bottom) {
GeometryReader { geometry in
ZStack(alignment: .topTrailing) {
KFImage(URL(string: content.coverImageUrl))
.cancelOnDisappear(true)
.resizable()
.scaledToFill()
.frame(width: geometry.size.width, height: geometry.size.width)
.clipShape(RoundedRectangle(cornerRadius: 5.3))
.clipped()
if content.isPointAvailable {
Image("ic_point")
.padding(.top, 2.7)
.padding(.trailing, 2.7)
}
}
}
.aspectRatio(1, contentMode: .fit)
HStack(spacing: 0) {
HStack(spacing: 2) {
if content.price > 0 {
Image("ic_card_can_gray_32")
}
Text(content.price > 0 ? "\(content.price)" : "무료")
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.white)
}
.padding(4)
.background(Color.gray33.opacity(0.7))
.cornerRadius(10)
Spacer()
Text(content.duration)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.white)
.padding(4)
.background(Color.gray33.opacity(0.7))
.cornerRadius(10)
}
.padding(.horizontal, 2.7)
.padding(.bottom, 2.7)
}
Text(content.title)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(.grayd2)
.lineLimit(1)
HStack(spacing: 5.3) {
KFImage(URL(string: content.creatorProfileImageUrl))
.cancelOnDisappear(true)
.downsampling(size: CGSize(width: 21, height: 21))
.resizable()
.frame(width: 21, height: 21)
.clipShape(Circle())
.clipped()
Text(content.creatorNickname)
.appFont(size: 10, weight: .medium)
.foregroundColor(.gray77)
}
.onTapGesture {
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
AppState.shared
.setAppStep(step: .creatorDetail(userId: content.creatorId))
} else {
AppState.shared
.setAppStep(step: .login)
}
}
}
.onTapGesture {
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
AppState.shared
.setAppStep(step: .contentDetail(contentId: content.contentId))
} else {
AppState.shared
.setAppStep(step: .login)
}
}
}
}
.padding(.horizontal, 13.3)
}
.onAppear {
if !self.creatorList.isEmpty {
selectedCreatorId = creatorList[0].creatorId
}
}
}
}
#Preview {
ContentByChannelView(
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"
)
],
contentList: [
GetAudioContentRankingItem(
contentId: 1,
title: "안녕하세요 오늘은 커버곡을 들려드릴께요....안녕하세요 오늘은 커버곡을 들려드릴께요....",
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
themeStr: "커버곡",
price: 100,
duration: "00:30:20",
creatorId: 1,
creatorNickname: "유저1",
isPointAvailable: true,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
),
GetAudioContentRankingItem(
contentId: 2,
title: "안녕하세요 오늘은 커버곡을 들려드릴께요....",
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
themeStr: "커버곡",
price: 0,
duration: "00:30:20",
creatorId: 1,
creatorNickname: "유저1",
isPointAvailable: false,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
),
GetAudioContentRankingItem(
contentId: 3,
title: "안녕하세요 오늘은 커버곡을 들려드릴께요....안녕하세요 오늘은 커버곡을 들려드릴께요....",
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
themeStr: "커버곡",
price: 50,
duration: "00:30:20",
creatorId: 1,
creatorNickname: "유저1",
isPointAvailable: false,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
),
GetAudioContentRankingItem(
contentId: 4,
title: "안녕하세요 오늘은 커버곡을 들려드릴께요....안녕하세요 오늘은 커버곡을 들려드릴께요....",
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
themeStr: "커버곡",
price: 50,
duration: "00:30:20",
creatorId: 1,
creatorNickname: "유저1",
isPointAvailable: false,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
)
]
) { _ in }
}

View File

@@ -1,57 +0,0 @@
//
// ContentCreatorView.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import SwiftUI
import Kingfisher
struct ContentCreatorView: View {
let isSelected: Bool
let item: ContentCreatorResponse
var body: some View {
VStack(spacing: 13.3) {
KFImage(URL(string: item.creatorProfileImageUrl))
.cancelOnDisappear(true)
.downsampling(size: CGSize(width: 60, height: 60))
.resizable()
.frame(width: 60, height: 60)
.clipShape(Circle())
.overlay(
Circle()
.strokeBorder(lineWidth: 3)
.foregroundColor(
.button
.opacity(isSelected ? 1 : 0)
)
)
Text(item.creatorNickname)
.appFont(size: 11.3, weight: .medium)
.foregroundColor(
isSelected ?
Color.button :
Color.graybb
)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(width: 60)
}
}
#Preview {
ContentCreatorView(
isSelected: true,
item: ContentCreatorResponse(
creatorId: 1,
creatorNickname: "유저1",
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png"
)
)
}

View File

@@ -1,90 +0,0 @@
//
// ContentMainCurationViewV2.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
struct ContentMainCurationViewV2: View {
let curationList: [GetContentCurationResponse]
var body: some View {
LazyVStack(spacing: 30) {
ForEach(0..<curationList.count, id: \.self) { index in
let curation = curationList[index]
ContentMainCurationItemViewV2(curation: curation)
}
}
}
}
struct ContentMainCurationItemViewV2: View {
let curation: GetContentCurationResponse
var body: some View {
VStack(alignment: .leading, spacing: 13.3) {
Text(curation.title)
.appFont(size: 18.3, weight: .bold)
.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]
ContentMainItemView(item: item)
}
}
.padding(.horizontal, 13.3)
}
}
}
}
#Preview {
ContentMainCurationViewV2(
curationList: [
GetContentCurationResponse(
title: "test1",
items: [
GetAudioContentMainItem(
contentId: 1,
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
title: "ㅓ처랴햐햫햐햐",
creatorId: 8,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
creatorNickname: "유저1",
price: 10,
duration: "00:00:30",
isPointAvailable: true
),
GetAudioContentMainItem(
contentId: 2,
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
title: "ㅓ처랴햐햫햐햐",
creatorId: 8,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
creatorNickname: "유저2",
price: 10,
duration: "00:00:30",
isPointAvailable: false
),
GetAudioContentMainItem(
contentId: 3,
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
title: "ㅓ처랴햐햫햐햐",
creatorId: 8,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
creatorNickname: "유저3",
price: 10,
duration: "00:00:30",
isPointAvailable: false
)
]
)
]
)
}

View File

@@ -1,103 +0,0 @@
//
// ContentMainNewContentViewV2.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
struct ContentMainNewContentViewV2: View {
let title: String
let onClickMore: () -> Void
let themeList: [String]
let contentList: [GetAudioContentMainItem]
let selectTheme: (String) -> Void
@State private var selectedTheme = "전체"
var body: some View {
LazyVStack(spacing: 13.3) {
HStack(spacing: 0) {
Text(title)
.appFont(size: 18.3, weight: .bold)
.foregroundColor(.grayee)
Spacer()
Image("ic_forward")
.resizable()
.frame(width: 20, height: 20)
.onTapGesture { onClickMore() }
}
.padding(.horizontal, 13.3)
if !themeList.isEmpty {
ContentMainContentThemeView(
themeList: themeList,
selectTheme: selectTheme,
selectedTheme: $selectedTheme
)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 13.3) {
ForEach(0..<contentList.count, id: \.self) { index in
ContentMainItemView(item: contentList[index])
}
}
.padding(.horizontal, 13.3)
}
}
.onAppear {
if !themeList.isEmpty {
selectedTheme = themeList[0]
}
}
}
}
#Preview {
ContentMainNewContentViewV2(
title: "새로운 단편",
onClickMore: {},
themeList: ["전체", "테스트1", "테스트2"],
contentList: [
GetAudioContentMainItem(
contentId: 1,
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
title: "ㅓ처랴햐햫햐햐",
creatorId: 8,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
creatorNickname: "유저1",
price: 10,
duration: "00:00:30",
isPointAvailable: false
),
GetAudioContentMainItem(
contentId: 2,
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
title: "ㅓ처랴햐햫햐햐",
creatorId: 8,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
creatorNickname: "유저2",
price: 10,
duration: "00:00:30",
isPointAvailable: false
),
GetAudioContentMainItem(
contentId: 3,
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
title: "ㅓ처랴햐햫햐햐",
creatorId: 8,
creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
creatorNickname: "유저3",
price: 10,
duration: "00:00:30",
isPointAvailable: false
)
],
selectTheme: { _ in }
)
}

View File

@@ -1,34 +0,0 @@
//
// 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("마이페이지에서 본인인증을 해주세요")
.appFont(size: 13, weight: .medium)
.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

@@ -1,251 +0,0 @@
//
// ContentMainViewV2.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
import Kingfisher
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 {
@StateObject var contentPlayManager = ContentPlayManager.shared
@StateObject var contentPlayerPlayManager = ContentPlayerPlayManager.shared
@State private var selectedTab: ContentMainTab = .SERIES
@State private var isShowPlayer = false
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 {
Group {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
HStack(spacing: 0) {
Text("콘텐츠 마켓")
.appFont(size: 21.3, weight: .bold)
.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)
.appFont(size: 16, weight: selectedTab == tabItem.tab ? .bold : .medium)
.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:
ContentMainTabContentView()
case .ALARM:
ContentMainTabAlarmView()
case .ASMR:
ContentMainTabAsmrView()
case .REPLAY:
ContentMainTabReplayView()
case .FREE:
ContentMainTabFreeView()
}
}
Spacer()
if contentPlayerPlayManager.isShowingMiniPlayer {
HStack(spacing: 0) {
KFImage(URL(string: contentPlayerPlayManager.coverImageUrl))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: 36.7,
height: 36.7
)
)
.resizable()
.frame(width: 36.7, height: 36.7)
.cornerRadius(5.3)
VStack(alignment: .leading, spacing: 2.3) {
Text(contentPlayerPlayManager.title)
.appFont(size: 13, weight: .medium)
.foregroundColor(Color.grayee)
.lineLimit(2)
Text(contentPlayerPlayManager.nickname)
.appFont(size: 11, weight: .medium)
.foregroundColor(Color.grayd2)
}
.padding(.horizontal, 10.7)
Spacer()
Image(contentPlayerPlayManager.isPlaying ? "ic_noti_pause" : "btn_bar_play")
.resizable()
.frame(width: 25, height: 25)
.onTapGesture {
contentPlayerPlayManager.playOrPause()
}
Image("ic_noti_stop")
.resizable()
.frame(width: 25, height: 25)
.padding(.leading, 16)
.onTapGesture { contentPlayerPlayManager.resetPlayer() }
}
.padding(.vertical, 10.7)
.padding(.horizontal, 13.3)
.background(Color.gray22)
.contentShape(Rectangle())
.onTapGesture {
isShowPlayer = true
}
}
if contentPlayManager.isShowingMiniPlayer {
HStack(spacing: 0) {
KFImage(URL(string: contentPlayManager.coverImage))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: 36.7,
height: 36.7
)
)
.resizable()
.frame(width: 36.7, height: 36.7)
.cornerRadius(5.3)
VStack(alignment: .leading, spacing: 2.3) {
Text(contentPlayManager.title)
.appFont(size: 13, weight: .medium)
.foregroundColor(Color.grayee)
.lineLimit(2)
Text(contentPlayManager.nickname)
.appFont(size: 11, weight: .medium)
.foregroundColor(Color.grayd2)
}
.padding(.horizontal, 10.7)
Spacer()
Image(contentPlayManager.isPlaying ? "ic_noti_pause" : "btn_bar_play")
.resizable()
.frame(width: 25, height: 25)
.onTapGesture {
if contentPlayManager.isPlaying {
contentPlayManager.pauseAudio()
} else {
contentPlayManager
.playAudio(contentId: contentPlayManager.contentId)
}
}
Image("ic_noti_stop")
.resizable()
.frame(width: 25, height: 25)
.padding(.leading, 16)
.onTapGesture { contentPlayManager.stopAudio() }
}
.padding(.vertical, 10.7)
.padding(.horizontal, 13.3)
.background(Color.gray22)
.contentShape(Rectangle())
.onTapGesture {
AppState.shared
.setAppStep(
step: .contentDetail(contentId: contentPlayManager.contentId)
)
}
}
}
if isShowPlayer {
ContentPlayerView(isShowing: $isShowPlayer, playlist: [])
}
}
.navigationBarHidden(true)
}
}
}
#Preview {
ContentMainViewV2(selectedTab: .SERIES)
}

View File

@@ -1,66 +0,0 @@
//
// ContentMainIntroduceCreatorAllView.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import SwiftUI
struct ContentMainIntroduceCreatorAllView: View {
@StateObject var viewModel = ContentMainIntroduceCreatorAllViewModel()
@State private var isInitialized = false
var body: some View {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 13.3) {
DetailNavigationBar(title: "크리에이터 소개")
ScrollView(.vertical, showsIndicators: false) {
let horizontalPadding: CGFloat = 16
let gridSpacing: CGFloat = 16
let itemSize = (screenSize().width - (horizontalPadding * 2) - gridSpacing) / 2
LazyVGrid(
columns: Array(
repeating: GridItem(
.flexible(),
spacing: gridSpacing,
alignment: .topLeading
),
count: 2
),
alignment: .leading,
spacing: gridSpacing
) {
ForEach(0..<viewModel.introduceCreatorList.count, id: \.self) { index in
let item = viewModel.introduceCreatorList[index]
ContentNewAllItemView(width: itemSize, item: item)
.onAppear {
if index == viewModel.introduceCreatorList.count - 1 {
viewModel.getIntroduceCreatorList()
}
}
}
}
.padding(.horizontal, 13.3)
}
}
.onAppear {
if !isInitialized {
viewModel.getIntroduceCreatorList()
isInitialized = true
}
}
}
.navigationBarHidden(true)
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
}
#Preview {
ContentMainIntroduceCreatorAllView()
}

View File

@@ -1,74 +0,0 @@
//
// ContentMainIntroduceCreatorAllViewModel.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import Foundation
import Combine
final class ContentMainIntroduceCreatorAllViewModel: ObservableObject {
private let repository = ContentMainTabFreeRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var introduceCreatorList: [GetAudioContentMainItem] = []
var page = 1
var isLast = false
private let size = 20
func getIntroduceCreatorList() {
if (!isLast && !isLoading) {
isLoading = true
repository.getIntroduceCreatorList(page: page, size: size)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
self.isLoading = false
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<[GetAudioContentMainItem]>.self, from: responseData)
self.isLoading = false
if let data = decoded.data, decoded.success {
if page == 1 {
introduceCreatorList.removeAll()
}
if !data.isEmpty {
page += 1
self.introduceCreatorList.append(contentsOf: data)
} else {
isLast = true
}
} 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

@@ -1,58 +0,0 @@
//
// ContentMainTabFreeRepository.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import Foundation
import CombineMoya
import Combine
import Moya
final class ContentMainTabFreeRepository {
private let api = MoyaProvider<ContentApi>()
func getContentMainFree() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getContentMainFree(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
func getIntroduceCreatorList(page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getIntroduceCreatorList(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size
)
)
}
func getNewContentOfTheme(theme: String, page: Int = 1, size: Int = 20) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getNewFreeContentOfTheme(
theme: theme,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size
)
)
}
func getPopularContentByCreator(creatorId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getPopularFreeContentByCreator(
creatorId: creatorId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
}

View File

@@ -1,87 +0,0 @@
//
// ContentMainTabFreeView.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import SwiftUI
struct ContentMainTabFreeView: View {
@StateObject var viewModel = ContentMainTabFreeViewModel()
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
if !viewModel.bannerList.isEmpty {
ContentMainBannerViewV2(bannerList: viewModel.bannerList)
.padding(.horizontal, 13.3)
}
if let introduceCreator = viewModel.introduceCreator {
ContentMainNewContentViewV2(
title: introduceCreator.title,
onClickMore: {
AppState.shared
.setAppStep(step: .introduceCreatorAll)
},
themeList: [],
contentList: introduceCreator.items
) { _ in }
.padding(.top, 30)
}
if !viewModel.recommendSeriesList.isEmpty {
ContentMainNewOrRecommendSeriesView(
title: "추천 무료 시리즈",
recommendSeriesList: viewModel.recommendSeriesList
)
.padding(.top, 30)
}
if !viewModel.themeList.isEmpty {
ContentMainNewContentViewV2(
title: "새로운 무료 콘텐츠",
onClickMore: {
AppState.shared
.setAppStep(step: .newContentAll(isFree: true))
},
themeList: viewModel.themeList,
contentList: viewModel.newFreeContentList
) {
viewModel.getNewContentOfTheme(theme: $0)
}
.padding(.top, 30)
}
if !viewModel.creatorList.isEmpty {
ContentByChannelView(
title: "채널별 추천 무료 콘텐츠",
creatorList: viewModel.creatorList,
contentList: viewModel.playCountRankContentList,
onClickCreator: {
viewModel.getPopularContentByCreator(creatorId: $0)
}
)
.padding(.top, 30)
}
if !viewModel.curationList.isEmpty {
ContentMainCurationViewV2(curationList: viewModel.curationList)
.padding(.top, 30)
}
}
.onAppear {
viewModel.fetchData()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
#Preview {
ContentMainTabFreeView()
}

View File

@@ -1,151 +0,0 @@
//
// ContentMainTabFreeViewModel.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import Foundation
import Combine
final class ContentMainTabFreeViewModel: ObservableObject {
private let repository = ContentMainTabFreeRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var bannerList: [GetAudioContentBannerResponse] = []
@Published var introduceCreator: GetContentCurationResponse? = nil
@Published var recommendSeriesList: [GetRecommendSeriesListResponse] = []
@Published var themeList: [String] = []
@Published var newFreeContentList: [GetAudioContentMainItem] = []
@Published var creatorList: [ContentCreatorResponse] = []
@Published var playCountRankContentList: [GetAudioContentRankingItem] = []
@Published var curationList: [GetContentCurationResponse] = []
func fetchData() {
isLoading = true
repository.getContentMainFree()
.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<GetContentMainTabFreeResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.bannerList = data.contentBannerList
self.introduceCreator = data.introduceCreator
self.recommendSeriesList = data.recommendSeriesList
self.newFreeContentList = data.newFreeContentList
self.creatorList = data.creatorList
self.playCountRankContentList = data.playCountRankContentList
self.curationList = data.curationList
self.themeList.removeAll()
self.themeList.append("전체")
self.themeList.append(contentsOf: data.themeList)
} 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 getNewContentOfTheme(theme: String) {
isLoading = true
repository.getNewContentOfTheme(theme: theme)
.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<[GetAudioContentMainItem]>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.newFreeContentList = 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 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.playCountRankContentList = 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

@@ -1,17 +0,0 @@
//
// GetContentMainTabFreeResponse.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
struct GetContentMainTabFreeResponse: Decodable {
let contentBannerList: [GetAudioContentBannerResponse]
let introduceCreator: GetContentCurationResponse?
let recommendSeriesList: [GetRecommendSeriesListResponse]
let themeList: [String]
let newFreeContentList: [GetAudioContentMainItem]
let creatorList: [ContentCreatorResponse]
let playCountRankContentList: [GetAudioContentRankingItem]
let curationList: [GetContentCurationResponse]
}

View File

@@ -1,11 +0,0 @@
//
// GetContentCurationResponse.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
struct GetContentCurationResponse: Decodable {
let title: String
let items: [GetAudioContentMainItem]
}

View File

@@ -1,38 +0,0 @@
//
// 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)
.appFont(size: 12, weight: .medium)
.foregroundColor(.gray77)
}
.onTapGesture {
onClick()
}
}
}
#Preview {
ContentMainTabCategoryView(
imageName: "ic_category_series",
title: "시리즈",
onClick: {}
)
}

View File

@@ -1,47 +0,0 @@
//
// 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)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(.white)
Spacer()
Text("자세히 >")
.appFont(size: 13.3, weight: .medium)
.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

@@ -1,44 +0,0 @@
//
// 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(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
func getPopularContentByCreator(creatorId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getPopularContentByCreator(
creatorId: creatorId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
func getContentRanking(sortType: String = "매출") -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getContentMainHomeContentRanking(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
sortType: sortType
)
)
}
}

View File

@@ -1,301 +0,0 @@
//
// ContentMainTabHomeView.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import SwiftUI
struct ContentMainTabHomeView: 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) {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) {
Text("보이스온")
.appFont(size: 21.3, weight: .bold)
.foregroundColor(Color.white)
.padding(.leading, 8)
Spacer()
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Image("ic_can")
.onTapGesture {
AppState
.shared
.setAppStep(step: .canCharge(refresh: {}))
}
}
}
.padding(.horizontal, 13.3)
if let notice = viewModel.noticeItem {
ContentMainTabHomeNoticeView(notice: notice) {
AppState.shared
.setAppStep(step: .noticeDetail(notice: $0))
}
.padding(.top, 15)
.padding(.horizontal, 13.3)
}
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
viewModel.bannerList.count > 0 {
ContentMainBannerViewV2(bannerList: viewModel.bannerList)
.padding(.top, 30)
.padding(.horizontal, 13.3)
}
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
HStack(spacing: 0) {
Image("ic_title_search_black")
Text("검색어를 2글자 이상 입력하세요")
.appFont(size: 13.3, weight: .medium)
.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: .search)
}
VStack(spacing: 13.3) {
HStack(spacing: 0) {
ContentMainTabCategoryView(
imageName: "ic_category_series",
title: "시리즈",
onClick: {
AppState.shared
.setAppStep(
step: .contentMain(
startTab: .SERIES
)
)
}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_content",
title: "단편",
onClick: {
AppState.shared
.setAppStep(
step: .contentMain(
startTab: .CONTENT
)
)
}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_alarm",
title: "모닝콜",
onClick: {
AppState.shared
.setAppStep(
step: .contentMain(
startTab: .ALARM
)
)
}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_asmr",
title: "ASMR",
onClick: {
AppState.shared
.setAppStep(
step: .contentMain(
startTab: .ASMR
)
)
}
)
.frame(maxWidth: .infinity)
}
HStack(spacing: 0) {
ContentMainTabCategoryView(
imageName: "ic_category_replay",
title: "다시듣기",
onClick: {
AppState.shared
.setAppStep(
step: .contentMain(
startTab: .REPLAY
)
)
}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_free",
title: "무료",
onClick: {
AppState.shared
.setAppStep(
step: .contentMain(
startTab: .FREE
)
)
}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_audio_book",
title: "오디오북",
onClick: {
viewModel.errorMessage = "준비중입니다."
viewModel.isShowPopup = true
}
)
.frame(maxWidth: .infinity)
ContentMainTabCategoryView(
imageName: "ic_category_audio_toon",
title: "오디오툰",
onClick: {
viewModel.errorMessage = "준비중입니다."
viewModel.isShowPopup = true
}
)
.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: {
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
AppState.shared.setAppStep(step: .contentRankingAll)
} else {
AppState.shared.setAppStep(step: .login)
}
},
sortList: !viewModel.rankSortTypeList.isEmpty ?
viewModel.rankSortTypeList :
[],
onClickSort: { viewModel.getContentRanking(sort: $0) },
contentList: viewModel.rankContentList
)
.padding(.top, 30)
}
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
viewModel.eventBannerList.count > 0 {
SectionEventBannerView(items: viewModel.eventBannerList)
.padding(.top, 30)
}
if !viewModel.contentRankCreatorList.isEmpty {
ContentByChannelView(
title: "채널별 인기 콘텐츠",
creatorList: viewModel.contentRankCreatorList,
contentList: viewModel.salesCountRankContentList,
onClickCreator: {
viewModel.getPopularContentByCreator(creatorId: $0)
}
)
.padding(.top, 30)
}
Text("""
- 회사명 : 주식회사 소다라이브
- 대표자 : 이재형
- 주소 : 경기도 성남시 분당구 황새울로335번길 10, 5층 563A호
- 사업자등록번호 : 870-81-03220
- 통신판매업신고 : 제2024-성남분당B-1012호
- 고객센터 : 02.2055.1477 (이용시간 10:00~19:00)
- 대표 이메일 : sodalive.official@gmail.com
""")
.appFont(size: 11, weight: .medium)
.foregroundColor(Color.gray77)
.padding(.top, 30)
}
.onAppear {
viewModel.fetchData()
}
}
if role == MemberRole.CREATOR.rawValue {
HStack(spacing: 5) {
Image("ic_thumb_play")
.resizable()
.frame(width: 20, height: 20)
Text("콘텐츠 업로드")
.appFont(size: 13.3, weight: .bold)
.foregroundColor(.white)
}
.padding(13.3)
.background(Color(hex: "3bb9f1"))
.cornerRadius(44)
.padding(.trailing, 16.7)
.padding(.bottom, 16.7)
.onTapGesture {
AppState.shared.setAppStep(step: .createContent)
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
}
#Preview {
ContentMainTabHomeView()
}

View File

@@ -1,152 +0,0 @@
//
// ContentMainTabHomeViewModel.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import Foundation
import Combine
final class ContentMainTabHomeViewModel: ObservableObject {
private let repository = ContentMainTabHomeRepository()
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.rankSeriesList = data.rankSeriesList
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
repository.getContentRanking(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<[GetAudioContentRankingItem]>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.rankContentList = 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 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

@@ -1,18 +0,0 @@
//
// 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

@@ -1,180 +0,0 @@
//
// ContentMainTabHomeRankCreatorView.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import SwiftUI
import Kingfisher
struct ContentMainTabHomeRankCreatorView: View {
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
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, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
VStack(spacing: 8) {
Text("\(desc)")
.appFont(size: 14.7, weight: .bold)
.foregroundColor(Color.grayee)
Text("※ 인기 순위는 매주 업데이트됩니다.")
.appFont(size: 13.3, weight: .light)
.foregroundColor(Color.graybb)
}
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(Color.gray22)
}
if let coloredTitle = response.coloredTitle, let color = response.color {
let titleArray = response.title.components(separatedBy: coloredTitle)
HStack(spacing: 0) {
Text(titleArray[0])
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.grayee)
Text(coloredTitle)
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: color))
if titleArray.count > 1 {
Text(titleArray[1])
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.grayee)
}
}
.padding(.top, token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0 : 30)
} else {
Text(response.title)
.appFont(size: 18.3, weight: .bold)
.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)
.appFont(size: 11.3, weight: .medium)
.foregroundColor(Color.grayee)
.lineLimit(1)
.frame(width: 93.3)
.padding(.top, 13.3)
Text(creator.tags)
.appFont(size: 10, weight: .medium)
.foregroundColor(Color.button)
.lineLimit(1)
.frame(width: 93.3)
.padding(.top, 3.3)
}
.contentShape(Rectangle())
.onTapGesture {
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
AppState.shared
.setAppStep(step: .creatorDetail(userId: creator.id))
} else {
AppState.shared
.setAppStep(step: .login)
}
}
}
}
}
.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",
follow: false
),
GetExplorerSectionCreatorResponse(
id: 2,
nickname: "User2",
tags: "",
profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
follow: false
),
GetExplorerSectionCreatorResponse(
id: 3,
nickname: "User3",
tags: "",
profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
follow: false
),
GetExplorerSectionCreatorResponse(
id: 4,
nickname: "User4",
tags: "",
profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
follow: false
)
]
)
)
}

View File

@@ -1,82 +0,0 @@
//
// 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("인기 시리즈")
.appFont(size: 18.3, weight: .bold)
.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
)
]
)
}

View File

@@ -1,93 +0,0 @@
//
// ContentMainReplayAllView.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import SwiftUI
struct ContentMainReplayAllView: View {
@StateObject var viewModel = ContentNewAllViewModel()
@State private var isInitialized = false
var body: some View {
Group {
BaseView(isLoading: $viewModel.isLoading) {
VStack(alignment: .leading, spacing: 13.3) {
DetailNavigationBar(title: "새로운 라이브 다시듣기")
Text("※ 최근 2주간 등록된 새로운 라이브 다시듣기 입니다.")
.appFont(size: 14.7, weight: .medium)
.foregroundColor(.graybb)
.padding(.horizontal, 13.3)
.padding(.vertical, 8)
.frame(width: screenSize().width, alignment: .leading)
.background(Color.gray22)
HStack(spacing: 0) {
Text("전체")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2"))
Text("\(viewModel.totalCount)")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "ff5c49"))
.padding(.leading, 8)
Text("")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2"))
.padding(.leading, 2)
}
.padding(.horizontal, 13.3)
ScrollView(.vertical, showsIndicators: false) {
let horizontalPadding: CGFloat = 16
let gridSpacing: CGFloat = 16
let itemSize = (screenSize().width - (horizontalPadding * 2) - gridSpacing) / 2
LazyVGrid(
columns: Array(
repeating: GridItem(
.flexible(),
spacing: gridSpacing,
alignment: .topLeading
),
count: 2
),
alignment: .leading,
spacing: gridSpacing
) {
ForEach(0..<viewModel.newContentList.count, id: \.self) { index in
ContentNewAllItemView(width: itemSize, item: viewModel.newContentList[index])
.onAppear {
if index == viewModel.newContentList.count - 1 {
viewModel.getNewContentList()
}
}
}
}
.padding(.horizontal, 13.3)
}
}
.onAppear {
if !isInitialized {
if viewModel.selectedTheme != "다시듣기" {
viewModel.selectedTheme = "다시듣기"
} else if viewModel.newContentList.isEmpty {
viewModel.getNewContentList()
}
isInitialized = true
}
}
}
.navigationBarHidden(true)
}
}
}
#Preview {
ContentMainReplayAllView()
}

View File

@@ -1,35 +0,0 @@
//
// ContentMainTabReplayRepository.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import Foundation
import CombineMoya
import Combine
import Moya
final class ContentMainTabReplayRepository {
private let api = MoyaProvider<ContentApi>()
func getContentMainReplay() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getContentMainReplay(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
func getPopularContentByCreator(creatorId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getPopularReplayContentByCreator(
creatorId: creatorId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
}

View File

@@ -1,69 +0,0 @@
//
// ContentMainTabReplayView.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import SwiftUI
struct ContentMainTabReplayView: View {
@StateObject var viewModel = ContentMainTabReplayViewModel()
var body: some View {
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.newReplayContentList.isEmpty {
ContentMainNewContentViewV2(
title: "새로운 라이브 다시듣기",
onClickMore: {
AppState.shared
.setAppStep(step: .newReplayContentAll)
},
themeList: [],
contentList: viewModel.newReplayContentList
) { _ in }
.padding(.top, 30)
}
if !viewModel.creatorList.isEmpty {
ContentByChannelView(
title: "채널별 라이브 다시듣기",
creatorList: viewModel.creatorList,
contentList: viewModel.salesCountRankContentList,
onClickCreator: {
viewModel.getPopularContentByCreator(creatorId: $0)
}
)
.padding(.top, 30)
}
if !viewModel.eventBannerList.isEmpty {
SectionEventBannerView(items: viewModel.eventBannerList)
.padding(.top, 30)
}
if !viewModel.curationList.isEmpty {
ContentMainCurationViewV2(curationList: viewModel.curationList)
.padding(.top, 30)
}
}
.onAppear {
viewModel.fetchData()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
#Preview {
ContentMainTabReplayView()
}

View File

@@ -1,106 +0,0 @@
//
// ContentMainTabReplayViewModel.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import Foundation
import Combine
final class ContentMainTabReplayViewModel: ObservableObject {
private let repository = ContentMainTabReplayRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var bannerList: [GetAudioContentBannerResponse] = []
@Published var newReplayContentList: [GetAudioContentMainItem] = []
@Published var creatorList: [ContentCreatorResponse] = []
@Published var salesCountRankContentList: [GetAudioContentRankingItem] = []
@Published var eventBannerList: [EventItem] = []
@Published var curationList: [GetContentCurationResponse] = []
func fetchData() {
isLoading = true
repository.getContentMainReplay()
.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<GetContentMainTabReplayResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.bannerList = data.contentBannerList
self.newReplayContentList = data.newLiveReplayContentList
self.creatorList = data.creatorList
self.salesCountRankContentList = data.salesCountRankContentList
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 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

@@ -1,15 +0,0 @@
//
// GetContentMainTabReplayResponse.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
struct GetContentMainTabReplayResponse: Decodable {
let contentBannerList: [GetAudioContentBannerResponse]
let newLiveReplayContentList: [GetAudioContentMainItem]
let creatorList: [ContentCreatorResponse]
let salesCountRankContentList: [GetAudioContentRankingItem]
let eventBannerList: GetEventResponse
let curationList: [GetContentCurationResponse]
}

View File

@@ -1,72 +0,0 @@
//
// CompletedSeriesView.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import SwiftUI
struct CompletedSeriesView: View {
@StateObject var viewModel = CompletedSeriesViewModel()
private let columns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 13.3) {
DetailNavigationBar(title: "완결 시리즈")
HStack(alignment: .center, spacing: 0) {
Text("전체")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(.graye2)
Text("\(viewModel.totalCount)")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(.mainRed)
.padding(.leading, 6.7)
Text("")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(.graye2)
Spacer()
}
.padding(.horizontal, 13.3)
ScrollView(.vertical, showsIndicators: false) {
LazyVGrid(columns: columns, spacing: 13.3) {
ForEach(0..<viewModel.rankCompleteSeriesList.count, id: \.self) { index in
let item = viewModel.rankCompleteSeriesList[index]
SeriesListItemView(
itemWidth: (screenSize().width - (13.3 * 4)) / 3,
item: item
)
.onAppear {
if index == viewModel.rankCompleteSeriesList.count - 1 {
viewModel.getCompletedSeries()
}
}
}
}
.padding(.horizontal, 13.3)
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
viewModel.getCompletedSeries()
}
}
}
#Preview {
CompletedSeriesView()
}

View File

@@ -1,73 +0,0 @@
//
// CompletedSeriesViewModel.swift
// SodaLive
//
// Created by klaus on 2/22/25.
//
import Foundation
import Combine
final class CompletedSeriesViewModel: ObservableObject {
private let repository = ContentMainTabSeriesRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var rankCompleteSeriesList: [SeriesListItem] = []
@Published var totalCount = 0
var isLast = false
var page = 1
private let size = 20
func getCompletedSeries() {
if !isLast && !isLoading {
isLoading = true
repository.getCompletedSeries(page: page, size: size)
.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<GetSeriesListResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
page += 1
if (data.items.count > 0) {
self.totalCount = data.totalCount
self.rankCompleteSeriesList = data.items
} else {
isLast = true
}
} 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

@@ -1,83 +0,0 @@
//
// 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("완결 시리즈")
.appFont(size: 18.3, weight: .bold)
.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

@@ -1,115 +0,0 @@
//
// 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)
.appFont(size: 18.3, weight: .bold)
.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)
.appFont(size: 13.3, weight: .medium)
.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)
.appFont(size: 10, weight: .medium)
.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

@@ -1,116 +0,0 @@
//
// ContentMainSeriesByGenreView.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
struct ContentMainSeriesByGenreView: View {
let genreList: [GetSeriesGenreListResponse]
let itemList: [SeriesListItem]
let onClickGenre: (Int) -> Void
@State private var selectedGenreId = 0
var body: some View {
VStack(alignment: .leading, spacing: 13.3) {
Text("장르별 추천 시리즈")
.appFont(size: 18.3, weight: .bold)
.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(
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

@@ -1,158 +0,0 @@
//
// 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)
.appFont(size: 18.3, weight: .bold)
.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

@@ -1,58 +0,0 @@
//
// ContentMainSeriesGenreView.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
struct ContentMainSeriesGenreView: View {
let genreList: [GetSeriesGenreListResponse]
let selectGenre: (Int) -> Void
@Binding var selectedGenreId: Int
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 8) {
ForEach(0..<genreList.count, id: \.self) { index in
let genre = genreList[index]
Text(genre.genre)
.appFont(size: 14.7, weight: .medium)
.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(
genreList: [
GetSeriesGenreListResponse(id: 1, genre: "test"),
GetSeriesGenreListResponse(id: 2, genre: "test2"),
GetSeriesGenreListResponse(id: 3, genre: "test3")
],
selectGenre: { _ in },
selectedGenreId: .constant(2)
)
}

View File

@@ -1,141 +0,0 @@
//
// ContentMainSeriesRankingView.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
import Kingfisher
struct ContentMainSeriesRankingView: View {
let seriesList: [SeriesListItem]
let rows = [
GridItem(.fixed(85), alignment: .leading),
GridItem(.fixed(85), alignment: .leading),
GridItem(.fixed(85), alignment: .leading)
]
var body: some View {
VStack(alignment: .leading, spacing: 13.3) {
Text("일간 랭킹")
.appFont(size: 18.3, weight: .bold)
.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)")
.appFont(size: 16.7, weight: .bold)
.foregroundColor(.button)
.padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 8) {
Text(series.title)
.lineLimit(2)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(.grayd2)
Text(series.creator.nickname)
.appFont(size: 11, weight: .medium)
.foregroundColor(.gray77)
}
}
.frame(width: 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

@@ -1,55 +0,0 @@
//
// ContentMainTabSeriesRepository.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
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(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
func getRecommendSeriesListByGenre(genreId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getRecommendSeriesListByGenre(
genreId: genreId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
func getRecommendSeriesByCreator(creatorId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getRecommendSeriesByCreator(
creatorId: creatorId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
}
func getCompletedSeries(page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getCompletedSeries(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size
)
)
}
}

View File

@@ -1,95 +0,0 @@
//
// ContentMainTabSeriesView.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
import SwiftUI
struct ContentMainTabSeriesView: View {
@StateObject var viewModel = ContentMainTabSeriesViewModel()
var body: some View {
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: {
AppState.shared
.setAppStep(step: .completedSeriesAll)
}
)
.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)
.padding(.top, 30)
}
if !viewModel.curationList.isEmpty {
ContentMainSeriesCurationView(curationList: viewModel.curationList)
.padding(.top, 30)
}
}
.onAppear {
viewModel.fetchData()
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
}
}
#Preview {
ContentMainTabSeriesView()
}

View File

@@ -1,157 +0,0 @@
//
// ContentMainTabSeriesViewModel.swift
// SodaLive
//
// Created by klaus on 2/20/25.
//
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) {
recommendSeriesByChannel = []
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,35 +5,7 @@
// Created by klaus on 2/20/25. // Created by klaus on 2/20/25.
// //
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 { struct GetSeriesGenreListResponse: Decodable {
let id: Int let id: Int
let genre: String 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

@@ -1,108 +0,0 @@
//
// ContentMainOriginalAudioDramaItemView.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
import Kingfisher
struct ContentMainOriginalAudioDramaItemView: View {
let itemWidth: CGFloat
let item: SeriesListItem
let isAll: Bool
var body: some View {
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)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.grayee)
.lineLimit(1)
if isAll {
Text(item.publishedDaysOfWeek)
.appFont(size: 11, weight: .medium)
.foregroundColor(Color.gray77)
}
}
.frame(width: itemWidth)
}
}
#Preview {
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

@@ -1,82 +0,0 @@
//
// ContentMainOriginalAudioDramaView.swift
// SodaLive
//
// Created by klaus on 2/21/25.
//
import SwiftUI
struct ContentMainOriginalAudioDramaView: View {
let itemList: [SeriesListItem]
let onClickMore: () -> Void
var body: some View {
VStack(spacing: 13.3) {
HStack(spacing: 0) {
Text("오리지널 오디오 드라마")
.appFont(size: 18.3, weight: .bold)
.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(
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

@@ -1,123 +0,0 @@
//
// 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: 13.3) {
Text(title)
.appFont(size: 18.3, weight: .bold)
.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: 13.3) {
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

@@ -290,24 +290,6 @@ struct AppStepLayerView: View {
case .search: case .search:
SearchView() SearchView()
case .contentMain(let startTab):
ContentMainViewV2(selectedTab: startTab)
case .completedSeriesAll:
CompletedSeriesView()
case .newAlarmContentAll:
ContentMainAlarmAllView()
case .newAsmrContentAll:
ContentMainAsmrAllView()
case .newReplayContentAll:
ContentMainReplayAllView()
case .introduceCreatorAll:
ContentMainIntroduceCreatorAllView()
case .message: case .message:
MessageView() MessageView()

View File

@@ -13,39 +13,47 @@ struct LiveRoomChatItemView: View {
let chatMessage: LiveRoomNormalChat let chatMessage: LiveRoomNormalChat
let onClickProfile: () -> Void let onClickProfile: () -> Void
private var rankValue: Int {
chatMessage.rank + 1
}
private var isTopRank: Bool {
(1...3).contains(rankValue)
}
private var topRankCrownImageName: String? {
switch rankValue {
case 1:
return "img_rank_1"
case 2:
return "img_rank_2"
case 3:
return "img_rank_3"
default:
return nil
}
}
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 13.3) { HStack(alignment: .top, spacing: 13.3) {
ZStack { ZStack {
switch chatMessage.rank + 1 { if !isTopRank {
case -2: switch rankValue {
Color(hex: "4999e3") case -2:
.frame(width: 33.3, height: 33.3, alignment: .top) Color(hex: "4999e3")
.clipShape(Circle()) .frame(width: 33.3, height: 33.3, alignment: .top)
.clipShape(Circle())
case -1: case -1:
Color.button Color.button
.frame(width: 33.3, height: 33.3, alignment: .top) .frame(width: 33.3, height: 33.3, alignment: .top)
.clipShape(Circle()) .clipShape(Circle())
case 1: default:
Color(hex: "fdca2f") Color(hex: "bbbbbb")
.frame(width: 33.3, height: 33.3, alignment: .top) .frame(width: 33.3, height: 33.3, alignment: .top)
.clipShape(Circle()) .clipShape(Circle())
}
case 2:
Color(hex: "dcdcdc")
.frame(width: 33.3, height: 33.3, alignment: .top)
.clipShape(Circle())
case 3:
Color(hex: "c67e4a")
.frame(width: 33.3, height: 33.3, alignment: .top)
.clipShape(Circle())
default:
Color(hex: "bbbbbb")
.frame(width: 33.3, height: 33.3, alignment: .top)
.clipShape(Circle())
} }
KFImage(URL(string: chatMessage.profileUrl)) KFImage(URL(string: chatMessage.profileUrl))
@@ -61,41 +69,35 @@ struct LiveRoomChatItemView: View {
.frame(width: 30, height: 30, alignment: .top) .frame(width: 30, height: 30, alignment: .top)
.clipShape(Circle()) .clipShape(Circle())
VStack(alignment: .trailing, spacing: 0) { if isTopRank {
Spacer() if let topRankCrownImageName {
Image(topRankCrownImageName)
switch chatMessage.rank + 1 {
case -2:
Image("ic_badge_manager")
.resizable() .resizable()
.frame(width: 16.7, height: 16.7) .frame(width: 39, height: 38)
case -1:
Image("ic_crown")
.resizable()
.frame(width: 16.7, height: 16.7)
case 1:
Image("ic_crown_1")
.resizable()
.frame(width: 16.7, height: 16.7)
case 2:
Image("ic_crown_2")
.resizable()
.frame(width: 16.7, height: 16.7)
case 3:
Image("ic_crown_3")
.resizable()
.frame(width: 16.7, height: 16.7)
default:
EmptyView()
} }
} else {
VStack(alignment: .trailing, spacing: 0) {
Spacer()
switch rankValue {
case -2:
Image("ic_badge_manager")
.resizable()
.frame(width: 16.7, height: 16.7)
case -1:
Image("ic_crown")
.resizable()
.frame(width: 16.7, height: 16.7)
default:
EmptyView()
}
}
.frame(width: 33.3, height: 33.3, alignment: .trailing)
} }
.frame(width: 33.3, height: 33.3, alignment: .trailing)
} }
.frame(width: isTopRank ? 39 : 33.3, height: isTopRank ? 38 : 33.3)
.onTapGesture { onClickProfile() } .onTapGesture { onClickProfile() }
VStack(alignment: .leading, spacing: 6.7) { VStack(alignment: .leading, spacing: 6.7) {

View File

@@ -14,17 +14,11 @@ struct LiveRoomDonationRankingItemView: View {
let item: GetLiveRoomDonationItem let item: GetLiveRoomDonationItem
let itemCount: Int let itemCount: Int
let rankingCrawns = ["ic_crown_1", "ic_crown_2", "ic_crown_3"] let crowns = ["img_rank_1", "img_rank_2", "img_rank_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 { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
ZStack { ZStack(alignment: .center) {
KFImage(URL(string: item.profileImage)) KFImage(URL(string: item.profileImage))
.cancelOnDisappear(true) .cancelOnDisappear(true)
.downsampling(size: CGSize(width: 60, height: 60)) .downsampling(size: CGSize(width: 60, height: 60))
@@ -32,26 +26,14 @@ struct LiveRoomDonationRankingItemView: View {
.scaledToFill() .scaledToFill()
.frame(width: 60, height: 60, alignment: .top) .frame(width: 60, height: 60, alignment: .top)
.clipShape(Circle()) .clipShape(Circle())
.overlay(
Circle()
.stroke(
AngularGradient(colors: rankingColors[index < 4 ? index : 3], center: .center),
lineWidth: 3
)
)
if index < 3 { if index < 3 {
VStack(alignment: .trailing, spacing: 0) { Image(crowns[index])
Spacer() .resizable()
.frame(width: 77, height: 75)
Image(rankingCrawns[index])
.resizable()
.frame(width: 25, height: 25)
}
.frame(width: 63, height: 63, alignment: .trailing)
} }
} }
.frame(width: 63, height: 63) .frame(width: 77, height: 75)
Text("\(index + 1)") Text("\(index + 1)")
.appFont(size: 13.3, weight: .bold) .appFont(size: 13.3, weight: .bold)

View File

@@ -14,17 +14,11 @@ struct LiveRoomHeartRankingItemView: View {
let item: GetLiveRoomHeartListItem let item: GetLiveRoomHeartListItem
let itemCount: Int let itemCount: Int
let rankingCrawns = ["ic_crown_1", "ic_crown_2", "ic_crown_3"] let crowns = ["img_rank_1", "img_rank_2", "img_rank_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 { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
ZStack { ZStack(alignment: .center) {
KFImage(URL(string: item.profileImage)) KFImage(URL(string: item.profileImage))
.cancelOnDisappear(true) .cancelOnDisappear(true)
.downsampling(size: CGSize(width: 60, height: 60)) .downsampling(size: CGSize(width: 60, height: 60))
@@ -32,26 +26,14 @@ struct LiveRoomHeartRankingItemView: View {
.scaledToFill() .scaledToFill()
.frame(width: 60, height: 60, alignment: .top) .frame(width: 60, height: 60, alignment: .top)
.clipShape(Circle()) .clipShape(Circle())
.overlay(
Circle()
.stroke(
AngularGradient(colors: rankingColors[index < 4 ? index : 3], center: .center),
lineWidth: 3
)
)
if index < 3 { if index < 3 {
VStack(alignment: .trailing, spacing: 0) { Image(crowns[index])
Spacer() .resizable()
.frame(width: 77, height: 75)
Image(rankingCrawns[index])
.resizable()
.frame(width: 25, height: 25)
}
.frame(width: 63, height: 63, alignment: .trailing)
} }
} }
.frame(width: 63, height: 63) .frame(width: 77, height: 75)
Text("\(index + 1)") Text("\(index + 1)")
.appFont(size: 13.3, weight: .bold) .appFont(size: 13.3, weight: .bold)

View File

@@ -40,23 +40,30 @@ struct HomeView: View {
@State private var pendingExternalNavigationAction: (() -> Void)? = nil @State private var pendingExternalNavigationAction: (() -> Void)? = nil
@State private var pendingExternalNavigationCancelAction: (() -> Void)? = nil @State private var pendingExternalNavigationCancelAction: (() -> Void)? = nil
@State private var payload = Payload() @State private var payload = Payload()
@State private var loadedTabs: Set<HomeViewModel.CurrentTab> = [AppState.shared.startTab]
var body: some View { var body: some View {
GeometryReader { proxy in GeometryReader { proxy in
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
VStack(spacing: 0) { VStack(spacing: 0) {
ZStack { ZStack {
homeTabView if loadedTabs.contains(.home) {
.frame(width: viewModel.currentTab == .home ? proxy.size.width : 0) homeTabView
.opacity(viewModel.currentTab == .home ? 1.0 : 0.01) .frame(width: viewModel.currentTab == .home ? proxy.size.width : 0)
.opacity(viewModel.currentTab == .home ? 1.0 : 0.01)
}
liveView if loadedTabs.contains(.live) {
.frame(width: viewModel.currentTab == .live ? proxy.size.width : 0) liveView
.opacity(viewModel.currentTab == .live ? 1.0 : 0.01) .frame(width: viewModel.currentTab == .live ? proxy.size.width : 0)
.opacity(viewModel.currentTab == .live ? 1.0 : 0.01)
}
chatTabView if loadedTabs.contains(.chat) {
.frame(width: viewModel.currentTab == .chat ? proxy.size.width : 0) chatTabView
.opacity(viewModel.currentTab == .chat ? 1.0 : 0.01) .frame(width: viewModel.currentTab == .chat ? proxy.size.width : 0)
.opacity(viewModel.currentTab == .chat ? 1.0 : 0.01)
}
if viewModel.currentTab == .mypage { if viewModel.currentTab == .mypage {
MyPageView() MyPageView()
@@ -183,6 +190,8 @@ struct HomeView: View {
} }
} }
.onAppear { .onAppear {
markTabAsLoaded(viewModel.currentTab)
payload.applicationId = BOOTPAY_APP_ID payload.applicationId = BOOTPAY_APP_ID
payload.price = 0 payload.price = 0
payload.pg = "다날" payload.pg = "다날"
@@ -197,6 +206,9 @@ struct HomeView: View {
viewModel.addAllPlaybackTracking() viewModel.addAllPlaybackTracking()
} }
} }
.valueChanged(value: viewModel.currentTab) { currentTab in
markTabAsLoaded(currentTab)
}
if appState.isShowNotificationSettingsDialog { if appState.isShowNotificationSettingsDialog {
NotificationSettingsDialog() NotificationSettingsDialog()
@@ -525,6 +537,10 @@ struct HomeView: View {
self.viewModel.pushTokenUpdate(pushToken: pushToken) self.viewModel.pushTokenUpdate(pushToken: pushToken)
} }
} }
private func markTabAsLoaded(_ tab: HomeViewModel.CurrentTab) {
loadedTabs.insert(tab)
}
} }
extension Notification.Name { extension Notification.Name {

View File

@@ -0,0 +1,22 @@
# 라이브룸 후원/하트 랭킹 왕관 UI 동일화
- [x] `UserProfileDonationAllItemView`의 왕관 UI 기준(`img_rank_1~3`, 오버레이 크기/정렬)을 확인한다.
- QA: 기준 코드에서 `ZStack(alignment: .center)` + `Image(crowns[index]).frame(width: 77, height: 75)` 구조 확인.
- [x] `LiveRoomDonationRankingItemView`의 왕관 표시를 기준 UI와 완전히 동일하게 변경한다.
- QA: 1~3위에서 `img_rank_1~3` 사용, 프로필 위 오버레이 프레임 `77x75`, 기존 원형 그라데이션 테두리 제거 확인.
- [x] 라이브 하트 랭킹 경로의 왕관 표시를 기준 UI와 완전히 동일하게 변경한다.
- QA: 하트 랭킹 아이템의 1~3위에서도 `img_rank_1~3` + `77x75` 오버레이가 동일하게 적용되는지 확인.
- [x] 변경 파일 진단/빌드/테스트를 수행하고 결과를 기록한다.
- QA: `lsp_diagnostics`(변경 파일), `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` 실행 결과 기록.
---
## 검증 기록
- 기준 확인: `SodaLive/Sources/Explorer/Profile/UserProfileDonationAllView.swift``UserProfileDonationAllItemView`에서 `crowns = ["img_rank_1", "img_rank_2", "img_rank_3"]`, `ZStack(alignment: .center)`, 왕관 오버레이 `frame(width: 77, height: 75)` 구조를 기준으로 확인.
- 코드 변경: `SodaLive/Sources/Live/Room/Dialog/LiveRoomDonationRankingItemView.swift`에서 `ic_crown_1~3` + 원형 그라데이션 테두리(`AngularGradient`)를 제거하고 `img_rank_1~3` 오버레이(`77x75`) 구조로 변경.
- 코드 변경: `SodaLive/Sources/Live/Room/Dialog/LiveRoomHeartRankingItemView.swift`에서 `ic_crown_1~3` + 원형 그라데이션 테두리를 제거하고 `img_rank_1~3` 오버레이(`77x75`) 구조로 변경.
- 수동 확인(코드 기반): 두 파일 모두 `img_rank_1~3`/`frame(width: 77, height: 75)`가 존재하고 `ic_crown_`, `AngularGradient`가 미존재함을 검색으로 확인.
- 진단 확인: `lsp_diagnostics` 실행 결과 두 변경 파일 모두 `No such module 'Kingfisher'` 1건씩 동일하게 표시됨(로컬 SourceKit 환경 이슈로 기록).
- 빌드 검증: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` 실행 결과 `** BUILD SUCCEEDED **` 확인.
- 테스트 검증: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` 실행 결과 `Scheme SodaLive is not currently configured for the test action.` 확인.

View File

@@ -0,0 +1,21 @@
# 라이브 채팅 순위 왕관 이미지 변경
- [x] `LiveRoomChatItemView`의 1~3위 왕관 asset을 `img_rank_1~3`으로 변경한다.
- QA: 1~3위 분기에서 `Image("img_rank_1")`, `Image("img_rank_2")`, `Image("img_rank_3")` 사용 여부 코드 확인.
- [x] 1~3위 왕관 표시 시 프로필 영역을 39x38로 조정하고 왕관이 프로필을 감싸도록 오버레이 구조로 변경한다.
- QA: 1~3위 분기에서 컨테이너/왕관 프레임이 `39x38`로 적용되고 배경 색상 원형이 제거되었는지 코드 확인.
- [x] `-2`, `-1` 케이스는 기존 배경 + 우측 하단 배지(16.7) 동작을 유지한다.
- QA: `ic_badge_manager`, `ic_crown` 분기와 `.frame(width: 16.7, height: 16.7)` 유지 여부 코드 확인.
- [x] 변경 파일 진단 및 빌드 검증을 수행한다.
- QA: `lsp_diagnostics`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` 결과 확인.
---
## 검증 기록
- 코드 변경: `SodaLive/Sources/Live/Room/Chat/LiveRoomChatItemView.swift`에서 `rankValue`, `isTopRank`, `topRankCrownImageName`을 추가하고, 1~3위는 `Image("img_rank_1~3")``39x38`로 오버레이하여 프로필(30x30)을 감싸도록 수정.
- 코드 변경: 1~3위 분기에서는 기존 배경 원(`fdca2f`, `dcdcdc`, `c67e4a`)과 우측 하단 소형 왕관(`ic_crown_1~3`) 표시를 제거.
- 코드 확인: `-2`, `-1`은 기존 원형 배경(`4999e3`, `Color.button`) + 우측 하단 배지(`ic_badge_manager`, `ic_crown`, `16.7x16.7`)를 유지.
- 진단 확인: `lsp_diagnostics` 실행 시 `No such module 'Kingfisher'`가 표시됨. 동일 import를 가진 `SodaLive/Sources/Home/HomeCreatorRankingItemView.swift`에서도 동일 진단 재현되어 로컬 SourceKit 환경 이슈로 기록.
- 빌드 검증: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` 실행 결과 `** BUILD SUCCEEDED **` 확인.
- 테스트 검증: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -destination "platform=iOS Simulator,name=iPhone 16,OS=18.3.1" test` 실행 결과 `Scheme SodaLive is not currently configured for the test action.` 확인.

View File

@@ -0,0 +1,19 @@
# 2026-03-18 홈 탭 초기 지연 로딩 및 상태 유지 구현 계획
## 체크리스트
- [x] 기존 탭 구성과 로딩 트리거 위치 확인
- [x] `HomeView`에서 현재 탭만 최초 마운트되도록 지연 로딩 구조 적용
- [x] 한 번 마운트된 탭은 재진입 시 상태가 유지되도록 구조 보장
- [x] 변경 파일 진단 및 빌드/테스트 검증
- [x] 검증 기록 문서화
## 완료 기준 (QA)
- [x] 앱 최초 진입 시 현재 탭(`home`)에 해당하는 화면만 생성되어 초기 조회가 발생한다.
- [x] `live`, `chat` 탭은 최초 탭 전환 시점에 한 번만 생성/조회가 시작된다.
- [x] 한 번 생성된 탭은 다른 탭으로 이동 후 재진입해도 기존 뷰 상태가 유지된다.
## 검증 기록
- [2026-03-18] 무엇: `HomeView` 탭 렌더링을 `loadedTabs` 기반 조건부 마운트로 변경. 왜: 초기 진입 시 모든 탭 `onAppear`/조회가 동시에 실행되는 문제를 제거하기 위해. 어떻게: `loadedTabs: Set<HomeViewModel.CurrentTab>` 추가 후 `valueChanged(value: viewModel.currentTab)`에서 탭 방문 시점에만 `insert` 하도록 구현.
- [2026-03-18] 무엇: 변경 파일 정적 진단. 왜: 문법/타입 오류 여부 확인. 어떻게: `lsp_diagnostics` 실행. 결과: `HomeView.swift`에서 `No such module 'Firebase'` 1건 확인(로컬 SourceKit 환경 이슈로 기존 import 해석 실패, 코드 변경으로 인한 신규 오류 아님).
- [2026-03-18] 무엇: 앱 빌드 검증. 왜: 실제 컴파일 가능 여부 확인. 어떻게: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` 실행. 결과: `** BUILD SUCCEEDED **`.
- [2026-03-18] 무엇: 테스트 실행 가능 여부 확인. 왜: 변경 영향 회귀 확인. 어떻게: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` 실행. 결과: `Scheme SodaLive is not currently configured for the test action.`로 테스트 액션 자체가 미구성 상태임을 확인.