콘텐츠 메인
- 홈 UI 페이지 생성
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_card_can_gray_32.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_card_can_gray_32.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_card_can_gray_32.imageset/ic_card_can_gray_32.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 588 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_alarm.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_category_alarm.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_alarm.imageset/ic_category_alarm.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.4 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_asmr.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_category_asmr.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_asmr.imageset/ic_category_asmr.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 4.1 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_audio_book.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_category_audio_book.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_audio_book.imageset/ic_category_audio_book.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.2 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_audio_toon.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_category_audio_toon.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_audio_toon.imageset/ic_category_audio_toon.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.6 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_content.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_category_content.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_content.imageset/ic_category_content.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.3 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_free.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_category_free.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_free.imageset/ic_category_free.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.0 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_replay.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_category_replay.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_replay.imageset/ic_category_replay.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.2 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_series.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_category_series.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_series.imageset/ic_category_series.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.2 KiB | 
| @@ -38,6 +38,8 @@ enum ContentApi { | ||||
|     case unpinContent(contentId: Int) | ||||
|     case getAudioContentByTheme(themeId: Int, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, sort: ContentAllByThemeViewModel.Sort) | ||||
|     case generateUrl(contentId: Int) | ||||
|     case getContentMainHome | ||||
|     case getPopularContentByCreator(creatorId: Int) | ||||
| } | ||||
|  | ||||
| extension ContentApi: TargetType { | ||||
| @@ -133,6 +135,12 @@ extension ContentApi: TargetType { | ||||
|              | ||||
|         case .generateUrl(let contentId): | ||||
|             return "/audio-content/\(contentId)/generate-url" | ||||
|              | ||||
|         case .getContentMainHome: | ||||
|             return "/v2/audio-content/main/home" | ||||
|              | ||||
|         case .getPopularContentByCreator: | ||||
|             return "/v2/audio-content/main/home/popular-content-by-creator" | ||||
|         } | ||||
|     } | ||||
|      | ||||
| @@ -147,6 +155,9 @@ extension ContentApi: TargetType { | ||||
|         case .getMainBannerList, .getMainOrderList, .getNewContentUploadCreatorList, .getCurationList, .getAudioContentByTheme: | ||||
|             return .get | ||||
|              | ||||
|         case .getContentMainHome, .getPopularContentByCreator: | ||||
|             return .get | ||||
|              | ||||
|         case .likeContent, .modifyAudioContent, .modifyComment, .unpinContent: | ||||
|             return .put | ||||
|              | ||||
| @@ -300,6 +311,13 @@ extension ContentApi: TargetType { | ||||
|             ] as [String : Any] | ||||
|              | ||||
|             return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) | ||||
|              | ||||
|         case .getContentMainHome: | ||||
|             return .requestPlain | ||||
|              | ||||
|         case .getPopularContentByCreator(let creatorId): | ||||
|             let parameters = ["creatorId": creatorId] | ||||
|             return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|   | ||||
| @@ -8,7 +8,7 @@ | ||||
| import Foundation | ||||
|  | ||||
| struct GetAudioContentMainResponse: Decodable { | ||||
|     let newContentUploadCreatorList: [GetNewContentUploadCreator] | ||||
|     let newContentUploadCreatorList: [ContentCreatorResponse] | ||||
|     let bannerList: [GetAudioContentBannerResponse] | ||||
|     let orderList: [GetAudioContentMainItem] | ||||
|     let themeList: [String] | ||||
| @@ -33,9 +33,10 @@ struct GetAudioContentRankingItem: Decodable { | ||||
|     let duration: String | ||||
|     let creatorId: Int | ||||
|     let creatorNickname: String | ||||
|     let creatorProfileImageUrl: String | ||||
| } | ||||
|  | ||||
| struct GetNewContentUploadCreator: Decodable { | ||||
| struct ContentCreatorResponse: Decodable { | ||||
|     let creatorId: Int | ||||
|     let creatorNickname: String | ||||
|     let creatorProfileImageUrl: String | ||||
|   | ||||
| @@ -0,0 +1,154 @@ | ||||
| // | ||||
| //  ContentMainTabRankContentView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2/20/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| import Kingfisher | ||||
|  | ||||
| struct ContentMainTabRankContentView: View { | ||||
|      | ||||
|     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) | ||||
|                     .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                  | ||||
|                 Spacer() | ||||
|                  | ||||
|                 if isMore { | ||||
|                     Image("ic_forward") | ||||
|                         .onTapGesture { onClickMore() } | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             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)") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                                 .foregroundColor(.button) | ||||
|                                 .padding(.horizontal, 12) | ||||
|                              | ||||
|                             VStack(alignment: .leading, spacing: 8) { | ||||
|                                 Text(content.title) | ||||
|                                     .lineLimit(2) | ||||
|                                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                                     .foregroundColor(.grayd2) | ||||
|                                  | ||||
|                                 Text(content.creatorNickname) | ||||
|                                     .font(.custom(Font.medium.rawValue, size: 11)) | ||||
|                                     .foregroundColor(.gray77) | ||||
|                             } | ||||
|                         } | ||||
|                         .frame(maxWidth: screenSize().width * 0.66, alignment: .leading) | ||||
|                         .contentShape(Rectangle()) | ||||
|                         .onTapGesture { | ||||
|                             AppState | ||||
|                                 .shared | ||||
|                                 .setAppStep(step: .contentDetail(contentId: content.contentId)) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 .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", | ||||
|                 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", | ||||
|                 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", | ||||
|                 creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||
|             ) | ||||
|         ] | ||||
|     ) | ||||
| } | ||||
							
								
								
									
										193
									
								
								SodaLive/Sources/Content/Main/V2/ContentByChannelView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,193 @@ | ||||
| // | ||||
| //  ContentByChannelView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2/20/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct ContentByChannelView: View { | ||||
|      | ||||
|     let title: String | ||||
|     let creatorList: [ContentCreatorResponse] | ||||
|     let contentList: [GetAudioContentRankingItem] | ||||
|     let onClickCreator: (Int) -> Void | ||||
|      | ||||
|     @State private var selectedCreatorId = 0 | ||||
|      | ||||
|     let columns = [GridItem(.flexible()), GridItem(.flexible())] | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading, spacing: 20) { | ||||
|             Text(title) | ||||
|                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|              | ||||
|             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 | ||||
|                             selectedCreatorId = creatorId | ||||
|                             onClickCreator(creatorId) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             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 | ||||
|                                 KFImage(URL(string: content.coverImageUrl)) | ||||
|                                     .cancelOnDisappear(true) | ||||
|                                     .resizable() | ||||
|                                     .scaledToFill() | ||||
|                                     .frame(width: geometry.size.width, height: geometry.size.width) | ||||
|                                     .clipShape(RoundedRectangle(cornerRadius: 5.3)) | ||||
|                                     .clipped() | ||||
|                             } | ||||
|                             .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)" : "무료") | ||||
|                                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                                         .foregroundColor(Color.white) | ||||
|                                 } | ||||
|                                 .padding(4) | ||||
|                                 .background(Color.gray33.opacity(0.7)) | ||||
|                                 .cornerRadius(10) | ||||
|                                  | ||||
|                                 Spacer() | ||||
|                                  | ||||
|                                 Text(content.duration) | ||||
|                                     .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                                     .foregroundColor(Color.white) | ||||
|                                     .padding(4) | ||||
|                                     .background(Color.gray33.opacity(0.7)) | ||||
|                                     .cornerRadius(10) | ||||
|                             } | ||||
|                             .padding(.horizontal, 2.7) | ||||
|                             .padding(.bottom, 2.7) | ||||
|                         } | ||||
|                          | ||||
|                         Text(content.title) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                             .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) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                                 .foregroundColor(.gray77) | ||||
|                         } | ||||
|                         .onTapGesture { | ||||
|                             AppState | ||||
|                                 .shared | ||||
|                                 .setAppStep(step: .creatorDetail(userId: content.creatorId)) | ||||
|                         } | ||||
|                     } | ||||
|                     .onTapGesture { | ||||
|                         AppState | ||||
|                             .shared | ||||
|                             .setAppStep(step: .contentDetail(contentId: content.contentId)) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|         } | ||||
|         .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", | ||||
|                 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", | ||||
|                 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", | ||||
|                 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", | ||||
|                 creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||
|             ) | ||||
|         ] | ||||
|     ) { _ in } | ||||
| } | ||||
							
								
								
									
										54
									
								
								SodaLive/Sources/Content/Main/V2/ContentCreatorView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,54 @@ | ||||
| // | ||||
| //  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) | ||||
|                 .font(.custom(Font.medium.rawValue, size: 11.3)) | ||||
|                 .foregroundColor( | ||||
|                     isSelected ? | ||||
|                     Color.button : | ||||
|                     Color.graybb | ||||
|                          | ||||
|                 ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     ContentCreatorView( | ||||
|         isSelected: true, | ||||
|         item: ContentCreatorResponse( | ||||
|             creatorId: 1, | ||||
|             creatorNickname: "유저1", | ||||
|             creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||
|         ) | ||||
|     ) | ||||
| } | ||||
							
								
								
									
										134
									
								
								SodaLive/Sources/Content/Main/V2/ContentMainBannerViewV2.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,134 @@ | ||||
| // | ||||
| //  ContentMainBannerViewV2.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2/20/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| import Kingfisher | ||||
|  | ||||
| struct ContentMainBannerViewV2: View { | ||||
|      | ||||
|     let bannerList: [GetAudioContentBannerResponse] | ||||
|      | ||||
|     @State var currentIndex = 0 | ||||
|     @State var timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() | ||||
|      | ||||
|     @State var width: CGFloat = 0 | ||||
|     @State var height: CGFloat = 0 | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(spacing: 0) { | ||||
|             TabView(selection: $currentIndex) { | ||||
|                 ForEach(0..<bannerList.count, id: \.self) { index in | ||||
|                     let item = bannerList[index] | ||||
|                     if let url = item.thumbnailImageUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { | ||||
|                         ContentMainBannerImageView( | ||||
|                             url: url, | ||||
|                             width: width, | ||||
|                             height: height, | ||||
|                             item: item | ||||
|                         ) | ||||
|                     } else { | ||||
|                         ContentMainBannerImageView( | ||||
|                             url: item.thumbnailImageUrl, | ||||
|                             width: width, | ||||
|                             height: height, | ||||
|                             item: item | ||||
|                         ) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) | ||||
|             .frame( | ||||
|                 width: width, | ||||
|                 height: height | ||||
|             ) | ||||
|              | ||||
|             HStack(spacing: 4) { | ||||
|                 ForEach(0..<bannerList.count, id: \.self) { index in | ||||
|                     Capsule() | ||||
|                         .foregroundColor( | ||||
|                             index == currentIndex | ||||
|                             ? .button | ||||
|                             : .gray90 | ||||
|                         ) | ||||
|                         .frame( | ||||
|                             width: index == currentIndex ? 18 : 6, | ||||
|                             height: 6 | ||||
|                         ) | ||||
|                         .tag(index) | ||||
|                 } | ||||
|             } | ||||
|             .padding(.top, 13.3) | ||||
|         } | ||||
|         .frame(maxWidth: .infinity) | ||||
|         .onAppear { | ||||
|             width = screenSize().width - 26.7 | ||||
|             height = width * 0.53 | ||||
|             timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() | ||||
|         } | ||||
|         .onDisappear { | ||||
|             timer.upstream.connect().cancel() | ||||
|         } | ||||
|         .onReceive(timer) { _ in | ||||
|             DispatchQueue.main.async { | ||||
|                 withAnimation { | ||||
|                     if currentIndex == bannerList.count - 1 { | ||||
|                         currentIndex = 0 | ||||
|                     } else { | ||||
|                         currentIndex += 1 | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     ContentMainBannerViewV2( | ||||
|         bannerList: [ | ||||
|             GetAudioContentBannerResponse( | ||||
|                 type: .CREATOR, | ||||
|                 thumbnailImageUrl: "https://test-cf.sodalive.net/audition/role/7/audition_role-dc4174e1-10b5-4a97-8379-c7a9336ab230-6691-1735908236571", | ||||
|                 eventItem: nil, | ||||
|                 creatorId: 2, | ||||
|                 seriesId: nil, | ||||
|                 link: nil | ||||
|             ) | ||||
|         ] | ||||
|     ) | ||||
| } | ||||
|  | ||||
| struct ContentMainBannerImageView: View { | ||||
|     let url: String | ||||
|     let width: CGFloat | ||||
|     let height: CGFloat | ||||
|     let item: GetAudioContentBannerResponse | ||||
|      | ||||
|     var body: some View { | ||||
|         KFImage(URL(string: url)) | ||||
|             .cancelOnDisappear(true) | ||||
|             .downsampling(size: CGSize(width: width, height: height)) | ||||
|             .resizable() | ||||
|             .scaledToFill() | ||||
|             .frame(width: width, height: height) | ||||
|             .cornerRadius(4.7) | ||||
|             .onTapGesture { | ||||
|                 switch item.type { | ||||
|                 case .EVENT: | ||||
|                     AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!)) | ||||
|                 case .CREATOR: | ||||
|                     AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!)) | ||||
|                 case .SERIES: | ||||
|                     AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId!)) | ||||
|                 case .LINK: | ||||
|                     if let link = item.link, link.trimmingCharacters(in: .whitespaces).count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { | ||||
|                         UIApplication.shared.open(url) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,38 @@ | ||||
| // | ||||
| //  ContentMainTabCategoryView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2/20/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct ContentMainTabCategoryView: View { | ||||
|      | ||||
|     let imageName: String | ||||
|     let title: String | ||||
|     let onClick: () -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(spacing: 5.3) { | ||||
|             Image(imageName) | ||||
|                 .resizable() | ||||
|                 .frame(width: 43, height: 43) | ||||
|              | ||||
|             Text(title) | ||||
|                 .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                 .foregroundColor(.gray77) | ||||
|         } | ||||
|         .onTapGesture { | ||||
|             onClick() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     ContentMainTabCategoryView( | ||||
|         imageName: "ic_category_series", | ||||
|         title: "시리즈", | ||||
|         onClick: {} | ||||
|     ) | ||||
| } | ||||
| @@ -0,0 +1,47 @@ | ||||
| // | ||||
| //  ContentMainTabHomeNoticeView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2/20/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct ContentMainTabHomeNoticeView: View { | ||||
|      | ||||
|     let notice: NoticeItem | ||||
|     let onClick: (NoticeItem) -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         HStack(spacing: 0) { | ||||
|             Text(notice.title) | ||||
|                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                 .foregroundColor(.white) | ||||
|              | ||||
|             Spacer() | ||||
|              | ||||
|             Text("자세히 >") | ||||
|                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                 .foregroundColor(.white) | ||||
|                 .onTapGesture { | ||||
|                     onClick(notice) | ||||
|                 } | ||||
|         } | ||||
|         .padding(.horizontal, 13.3) | ||||
|         .padding(.vertical, 10) | ||||
|         .background(Color.gray22) | ||||
|         .cornerRadius(5.3) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     ContentMainTabHomeNoticeView( | ||||
|         notice: NoticeItem( | ||||
|             title: "[업데이트] 1.28.0 버전 업데이트", | ||||
|             content: "test", | ||||
|             date: "2025-02-07" | ||||
|         ) | ||||
|     ) { | ||||
|         AppState.shared.setAppStep(step: .noticeDetail(notice: $0)) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,23 @@ | ||||
| // | ||||
| //  ContentMainTabHomeRepository.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2/20/25. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import CombineMoya | ||||
| import Combine | ||||
| import Moya | ||||
|  | ||||
| class ContentMainTabHomeRepository { | ||||
|     private let api = MoyaProvider<ContentApi>() | ||||
|      | ||||
|     func getContentMainHome() -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getContentMainHome) | ||||
|     } | ||||
|      | ||||
|     func getPopularContentByCreator(creatorId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getPopularContentByCreator(creatorId: creatorId)) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,238 @@ | ||||
| // | ||||
| //  ContentMainTabHomeView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2/20/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct ContentMainTabHomeView: View { | ||||
|      | ||||
|     @StateObject var viewModel = ContentMainTabHomeViewModel() | ||||
|      | ||||
|     var body: some View { | ||||
|         BaseView(isLoading: $viewModel.isLoading) { | ||||
|             ScrollView(.vertical, showsIndicators: false) { | ||||
|                 VStack(alignment: .leading, spacing: 0) { | ||||
|                     HStack(spacing: 0) { | ||||
|                         Text("콘텐츠 마켓") | ||||
|                             .font(.custom(Font.bold.rawValue, size: 21.3)) | ||||
|                             .foregroundColor(Color.button) | ||||
|                          | ||||
|                         Spacer() | ||||
|                          | ||||
|                         Image("ic_content_keep") | ||||
|                             .onTapGesture { | ||||
|                                 AppState.shared.setAppStep(step: .myBox(currentTab: .orderlist)) | ||||
|                             } | ||||
|                     } | ||||
|                     .padding(.bottom, 26.7) | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                      | ||||
|                     if let notice = viewModel.noticeItem { | ||||
|                         ContentMainTabHomeNoticeView(notice: notice) { | ||||
|                             AppState.shared | ||||
|                                 .setAppStep(step: .noticeDetail(notice: $0)) | ||||
|                         } | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                     } | ||||
|                      | ||||
|                     if viewModel.bannerList.count > 0 { | ||||
|                         ContentMainBannerViewV2(bannerList: viewModel.bannerList) | ||||
|                             .padding(.top, 30) | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                     } | ||||
|                      | ||||
|                     HStack(spacing: 0) { | ||||
|                         Image("ic_title_search_black") | ||||
|                          | ||||
|                         Text("채널명을 입력해 보세요") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                             .foregroundColor(Color.gray55) | ||||
|                             .keyboardType(.default) | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                          | ||||
|                         Spacer() | ||||
|                     } | ||||
|                     .padding(.horizontal, 21.3) | ||||
|                     .frame(height: 50) | ||||
|                     .frame(maxWidth: .infinity) | ||||
|                     .background(Color.gray22) | ||||
|                     .overlay( | ||||
|                         RoundedRectangle(cornerRadius: 6.7) | ||||
|                             .strokeBorder(lineWidth: 1) | ||||
|                             .foregroundColor(Color.graybb) | ||||
|                     ) | ||||
|                     .padding(.top, 30) | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                     .onTapGesture { | ||||
|                         UserDefaults.set("", forKey: .searchChannel) | ||||
|                         AppState.shared.setAppStep(step: .searchChannel) | ||||
|                     } | ||||
|                      | ||||
|                     VStack(spacing: 13.3) { | ||||
|                         HStack(spacing: 0) { | ||||
|                             ContentMainTabCategoryView( | ||||
|                                 imageName: "ic_category_series", | ||||
|                                 title: "시리즈", | ||||
|                                 onClick: {} | ||||
|                             ) | ||||
|                             .frame(maxWidth: .infinity) | ||||
|                              | ||||
|                             ContentMainTabCategoryView( | ||||
|                                 imageName: "ic_category_content", | ||||
|                                 title: "단편", | ||||
|                                 onClick: {} | ||||
|                             ) | ||||
|                             .frame(maxWidth: .infinity) | ||||
|                              | ||||
|                             ContentMainTabCategoryView( | ||||
|                                 imageName: "ic_category_audio_book", | ||||
|                                 title: "오디오북", | ||||
|                                 onClick: {} | ||||
|                             ) | ||||
|                             .frame(maxWidth: .infinity) | ||||
|                              | ||||
|                             ContentMainTabCategoryView( | ||||
|                                 imageName: "ic_category_alarm", | ||||
|                                 title: "모닝콜", | ||||
|                                 onClick: {} | ||||
|                             ) | ||||
|                             .frame(maxWidth: .infinity) | ||||
|                         } | ||||
|                          | ||||
|                         HStack(spacing: 0) { | ||||
|                             ContentMainTabCategoryView( | ||||
|                                 imageName: "ic_category_asmr", | ||||
|                                 title: "ASMR", | ||||
|                                 onClick: {} | ||||
|                             ) | ||||
|                             .frame(maxWidth: .infinity) | ||||
|                              | ||||
|                             ContentMainTabCategoryView( | ||||
|                                 imageName: "ic_category_replay", | ||||
|                                 title: "다시듣기", | ||||
|                                 onClick: {} | ||||
|                             ) | ||||
|                             .frame(maxWidth: .infinity) | ||||
|                              | ||||
|                             ContentMainTabCategoryView( | ||||
|                                 imageName: "ic_category_audio_toon", | ||||
|                                 title: "오디오툰", | ||||
|                                 onClick: {} | ||||
|                             ) | ||||
|                             .frame(maxWidth: .infinity) | ||||
|                              | ||||
|                             ContentMainTabCategoryView( | ||||
|                                 imageName: "ic_category_free", | ||||
|                                 title: "무료", | ||||
|                                 onClick: {} | ||||
|                             ) | ||||
|                             .frame(maxWidth: .infinity) | ||||
|                         } | ||||
|                     } | ||||
|                     .padding(.vertical, 13.3) | ||||
|                     .background(Color.gray22) | ||||
|                     .cornerRadius(5.3) | ||||
|                     .padding(.top, 30) | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                      | ||||
|                     if let response = viewModel.rankCreatorResponse { | ||||
|                         ContentMainTabHomeRankCreatorView(response: response) | ||||
|                             .padding(.top, 30) | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                     } | ||||
|                      | ||||
|                     if !viewModel.rankSeriesList.isEmpty { | ||||
|                         ContentMainTabHomeRankSeriesView(seriesList: viewModel.rankSeriesList) | ||||
|                             .padding(.top, 30) | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                     } | ||||
|                      | ||||
|                     if !viewModel.rankSortTypeList.isEmpty { | ||||
|                         ContentMainTabRankContentView( | ||||
|                             title: "인기 단편", | ||||
|                             isMore: true, | ||||
|                             onClickMore: { | ||||
|                                 AppState.shared.setAppStep(step: .contentRankingAll) | ||||
|                             }, | ||||
|                             sortList: !viewModel.rankSortTypeList.isEmpty ? | ||||
|                             viewModel.rankSortTypeList : | ||||
|                                 [], | ||||
|                             onClickSort: { viewModel.getContentRanking(sort: $0) }, | ||||
|                             contentList: viewModel.rankContentList | ||||
|                         ) | ||||
|                         .padding(.top, 30) | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                     } | ||||
|                      | ||||
|                     if viewModel.eventBannerList.count > 0 { | ||||
|                         SectionEventBannerView(items: viewModel.eventBannerList) | ||||
|                             .frame( | ||||
|                                 width: viewModel.eventBannerList.count > 0 ? screenSize().width : 0, | ||||
|                                 height: viewModel.eventBannerList.count > 0 ? screenSize().width * 300 / 1000 : 0, | ||||
|                                 alignment: .center | ||||
|                             ) | ||||
|                             .padding(.top, 30) | ||||
|                     } | ||||
|                      | ||||
|                     if !viewModel.contentRankCreatorList.isEmpty { | ||||
|                         ContentByChannelView( | ||||
|                             title: "채널별 인기 콘텐츠", | ||||
|                             creatorList: viewModel.contentRankCreatorList, | ||||
|                             contentList: viewModel.salesCountRankContentList, | ||||
|                             onClickCreator: { | ||||
|                                 viewModel.getPopularContentByCreator(creatorId: $0) | ||||
|                             } | ||||
|                         ) | ||||
|                         .padding(.top, 30) | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                     } | ||||
|                      | ||||
|                     Text(""" | ||||
| - 회사명 : 주식회사 소다라이브 | ||||
|  | ||||
| - 대표자 : 이재형 | ||||
|  | ||||
| - 주소 : 경기도 성남시 분당구 황새울로335번길 10, 5층 563A호 | ||||
|  | ||||
| - 사업자등록번호 : 870-81-03220 | ||||
|  | ||||
| - 통신판매업신고 : 제2024-성남분당B-1012호 | ||||
|  | ||||
| - 고객센터 : 02.2055.1477 (이용시간 10:00~19:00) | ||||
|  | ||||
| - 대표 이메일 : sodalive.official@gmail.com | ||||
| """) | ||||
|                     .font(.custom(Font.medium.rawValue, size: 11)) | ||||
|                     .foregroundColor(Color.gray77) | ||||
|                     .padding(.top, 30) | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                 } | ||||
|                 .onAppear { | ||||
|                     viewModel.fetchData() | ||||
|                 } | ||||
|             } | ||||
|             .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) { | ||||
|                 HStack { | ||||
|                     Spacer() | ||||
|                     Text(viewModel.errorMessage) | ||||
|                         .padding(.vertical, 13.3) | ||||
|                         .frame(width: screenSize().width - 66.7, alignment: .center) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                         .background(Color.button) | ||||
|                         .foregroundColor(Color.white) | ||||
|                         .multilineTextAlignment(.leading) | ||||
|                         .cornerRadius(20) | ||||
|                         .padding(.bottom, 66.7) | ||||
|                     Spacer() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     ContentMainTabHomeView() | ||||
| } | ||||
| @@ -0,0 +1,152 @@ | ||||
| // | ||||
| //  ContentMainTabHomeViewModel.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2/20/25. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import Combine | ||||
|  | ||||
| final class ContentMainTabHomeViewModel: ObservableObject { | ||||
|      | ||||
|     private let repository = ContentMainTabHomeRepository() | ||||
|     private let contentRepository = ContentRepository() | ||||
|     private var subscription = Set<AnyCancellable>() | ||||
|      | ||||
|     @Published var errorMessage = "" | ||||
|     @Published var isShowPopup = false | ||||
|     @Published var isLoading = false | ||||
|   | ||||
|     @Published var noticeItem: NoticeItem? = nil | ||||
|     @Published var bannerList = [GetAudioContentBannerResponse]() | ||||
|     @Published var rankCreatorResponse: GetExplorerSectionResponse? = nil | ||||
|     @Published var rankSeriesList = [SeriesListItem]() | ||||
|     @Published var rankSortTypeList: [String] = [] | ||||
|     @Published var rankContentList: [GetAudioContentRankingItem] = [] | ||||
|     @Published var eventBannerList: [EventItem] = [] | ||||
|     @Published var contentRankCreatorList: [ContentCreatorResponse] = [] | ||||
|     @Published var salesCountRankContentList: [GetAudioContentRankingItem] = [] | ||||
|      | ||||
|     func fetchData() { | ||||
|         isLoading = true | ||||
|          | ||||
|         repository.getContentMainHome() | ||||
|             .sink { result in | ||||
|                 switch result { | ||||
|                 case .finished: | ||||
|                     DEBUG_LOG("finish") | ||||
|                 case .failure(let error): | ||||
|                     ERROR_LOG(error.localizedDescription) | ||||
|                 } | ||||
|             } receiveValue: { [unowned self] response in | ||||
|                 let responseData = response.data | ||||
|                  | ||||
|                 do { | ||||
|                     let jsonDecoder = JSONDecoder() | ||||
|                     let decoded = try jsonDecoder.decode(ApiResponse<GetContentMainTabHomeResponse>.self, from: responseData) | ||||
|                      | ||||
|                     if let data = decoded.data, decoded.success { | ||||
|                         self.noticeItem = data.latestNotice | ||||
|                         self.bannerList = data.bannerList | ||||
|                         self.rankCreatorResponse = data.rankCreatorList | ||||
|                         self.rankSortTypeList = data.rankSortTypeList | ||||
|                         self.rankContentList = data.rankContentList | ||||
|                         self.eventBannerList = data.eventBannerList.eventList | ||||
|                         self.contentRankCreatorList = data.contentRankCreatorList | ||||
|                         self.salesCountRankContentList = data.salesCountRankContentList | ||||
|                     } else { | ||||
|                         if let message = decoded.message { | ||||
|                             self.errorMessage = message | ||||
|                         } else { | ||||
|                             self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         } | ||||
|                          | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                 } catch { | ||||
|                     self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                     self.isShowPopup = true | ||||
|                 } | ||||
|                  | ||||
|                 self.isLoading = false | ||||
|             } | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
|      | ||||
|     func getContentRanking(sort: String = "매출") { | ||||
|         isLoading = true | ||||
|         contentRepository.getContentRanking(page: 1, size: 12, sortType: sort) | ||||
|             .sink { result in | ||||
|                 switch result { | ||||
|                 case .finished: | ||||
|                     DEBUG_LOG("finish") | ||||
|                 case .failure(let error): | ||||
|                     ERROR_LOG(error.localizedDescription) | ||||
|                 } | ||||
|             } receiveValue: { [unowned self] response in | ||||
|                 let responseData = response.data | ||||
|                  | ||||
|                 do { | ||||
|                     let jsonDecoder = JSONDecoder() | ||||
|                     let decoded = try jsonDecoder.decode(ApiResponse<GetAudioContentRanking>.self, from: responseData) | ||||
|                      | ||||
|                     if let data = decoded.data, decoded.success { | ||||
|                         self.rankContentList = data.items | ||||
|                     } else { | ||||
|                         if let message = decoded.message { | ||||
|                             self.errorMessage = message | ||||
|                         } else { | ||||
|                             self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         } | ||||
|                          | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                 } catch { | ||||
|                     self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                     self.isShowPopup = true | ||||
|                 } | ||||
|                  | ||||
|                 self.isLoading = false | ||||
|             } | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
|      | ||||
|     func getPopularContentByCreator(creatorId: Int) { | ||||
|         isLoading = true | ||||
|         repository.getPopularContentByCreator(creatorId: creatorId) | ||||
|             .sink { result in | ||||
|                 switch result { | ||||
|                 case .finished: | ||||
|                     DEBUG_LOG("finish") | ||||
|                 case .failure(let error): | ||||
|                     ERROR_LOG(error.localizedDescription) | ||||
|                 } | ||||
|             } receiveValue: { [unowned self] response in | ||||
|                 let responseData = response.data | ||||
|                  | ||||
|                 do { | ||||
|                     let jsonDecoder = JSONDecoder() | ||||
|                     let decoded = try jsonDecoder.decode(ApiResponse<[GetAudioContentRankingItem]>.self, from: responseData) | ||||
|                      | ||||
|                     if let data = decoded.data, decoded.success { | ||||
|                         self.salesCountRankContentList = data | ||||
|                     } else { | ||||
|                         if let message = decoded.message { | ||||
|                             self.errorMessage = message | ||||
|                         } else { | ||||
|                             self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         } | ||||
|                          | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                 } catch { | ||||
|                     self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                     self.isShowPopup = true | ||||
|                 } | ||||
|                  | ||||
|                 self.isLoading = false | ||||
|             } | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| // | ||||
| //  GetContentMainTabHomeResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2/20/25. | ||||
| // | ||||
|  | ||||
| struct GetContentMainTabHomeResponse: Decodable { | ||||
|     let latestNotice: NoticeItem? | ||||
|     let bannerList: [GetAudioContentBannerResponse] | ||||
|     let rankCreatorList: GetExplorerSectionResponse | ||||
|     let rankSeriesList: [SeriesListItem] | ||||
|     let rankSortTypeList: [String] | ||||
|     let rankContentList: [GetAudioContentRankingItem] | ||||
|     let eventBannerList: GetEventResponse | ||||
|     let contentRankCreatorList: [ContentCreatorResponse] | ||||
|     let salesCountRankContentList: [GetAudioContentRankingItem] | ||||
| } | ||||
| @@ -0,0 +1,170 @@ | ||||
| // | ||||
| //  ContentMainTabHomeRankCreatorView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2/20/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct ContentMainTabHomeRankCreatorView: View { | ||||
|      | ||||
|     let response: GetExplorerSectionResponse | ||||
|      | ||||
|     let rankingCrawns = ["ic_crown_1", "ic_crown_2", "ic_crown_3"] | ||||
|     let rankingColors = [ | ||||
|         [Color(hex: "ffdc00"), Color(hex: "ffb600")], | ||||
|         [Color(hex: "ffffff"), Color(hex: "9f9f9f")], | ||||
|         [Color(hex: "e6a77a"), Color(hex: "c67e4a")], | ||||
|         [Color(hex: "ffffff").opacity(0), Color(hex: "ffffff").opacity(0)] | ||||
|     ] | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading, spacing: 0) { | ||||
|             if let desc = response.desc { | ||||
|                 VStack(spacing: 8) { | ||||
|                     Text("\(desc)") | ||||
|                         .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                         .foregroundColor(Color.grayee) | ||||
|                      | ||||
|                     Text("※ 인기 순위는 매주 업데이트됩니다.") | ||||
|                         .font(.custom(Font.light.rawValue, size: 13.3)) | ||||
|                         .foregroundColor(Color.graybb) | ||||
|                 } | ||||
|                 .padding(.vertical, 8) | ||||
|                 .frame(maxWidth: .infinity) | ||||
|                 .background(Color.gray22) | ||||
|                 .padding(.top, 13.3) | ||||
|             } | ||||
|              | ||||
|             if let coloredTitle = response.coloredTitle, let color = response.color { | ||||
|                 let titleArray = response.title.components(separatedBy: coloredTitle) | ||||
|                 HStack(spacing: 0) { | ||||
|                     Text(titleArray[0]) | ||||
|                         .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                         .foregroundColor(Color.grayee) | ||||
|                      | ||||
|                     Text(coloredTitle) | ||||
|                         .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                         .foregroundColor(Color(hex: color)) | ||||
|                      | ||||
|                     if titleArray.count > 1 { | ||||
|                         Text(titleArray[1]) | ||||
|                             .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                             .foregroundColor(Color.grayee) | ||||
|                     } | ||||
|                 } | ||||
|                 .padding(.top, 30) | ||||
|             } else { | ||||
|                 Text(response.title) | ||||
|                     .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                     .foregroundColor(Color.grayee) | ||||
|                     .padding(.top, 30) | ||||
|             } | ||||
|              | ||||
|             ScrollView(.horizontal, showsIndicators: false) { | ||||
|                 HStack(spacing: 13.3) { | ||||
|                     ForEach(0..<response.creators.count, id: \.self) { index in | ||||
|                         let creator = response.creators[index] | ||||
|                         VStack(spacing: 0) { | ||||
|                             if let _ = response.desc { | ||||
|                                 ZStack { | ||||
|                                     KFImage(URL(string: creator.profileImageUrl)) | ||||
|                                         .cancelOnDisappear(true) | ||||
|                                         .downsampling(size: CGSize(width: 90, height: 90)) | ||||
|                                         .resizable() | ||||
|                                         .clipShape(Circle()) | ||||
|                                         .frame(width: 90, height: 90) | ||||
|                                         .overlay( | ||||
|                                             Circle() | ||||
|                                                 .stroke( | ||||
|                                                     AngularGradient(colors: rankingColors[index < 4 ? index : 3], center: .center), | ||||
|                                                     lineWidth: 3 | ||||
|                                                 ) | ||||
|                                         ) | ||||
|                                      | ||||
|                                     if index < 3 { | ||||
|                                         VStack(alignment: .trailing, spacing: 0) { | ||||
|                                             Spacer() | ||||
|                                              | ||||
|                                             Image(rankingCrawns[index]) | ||||
|                                                 .resizable() | ||||
|                                                 .frame(width: 37, height: 37) | ||||
|                                         } | ||||
|                                         .frame(width: 93.3, height: 93.3, alignment: .trailing) | ||||
|                                     } | ||||
|                                 } | ||||
|                                 .frame(width: 93.3, height: 93.3) | ||||
|                             } else { | ||||
|                                 KFImage(URL(string: creator.profileImageUrl)) | ||||
|                                     .cancelOnDisappear(true) | ||||
|                                     .downsampling(size: CGSize(width: 93, height: 93)) | ||||
|                                     .resizable() | ||||
|                                     .clipShape(Circle()) | ||||
|                                     .frame(width: 93, height: 93) | ||||
|                             } | ||||
|                              | ||||
|                             Text(creator.nickname) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 11.3)) | ||||
|                                 .foregroundColor(Color.grayee) | ||||
|                                 .lineLimit(1) | ||||
|                                 .frame(width: 93.3) | ||||
|                                 .padding(.top, 13.3) | ||||
|                              | ||||
|                             Text(creator.tags) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                                 .foregroundColor(Color.button) | ||||
|                                 .lineLimit(1) | ||||
|                                 .frame(width: 93.3) | ||||
|                                 .padding(.top, 3.3) | ||||
|                         } | ||||
|                         .contentShape(Rectangle()) | ||||
|                         .onTapGesture { | ||||
|                             AppState.shared | ||||
|                                 .setAppStep(step: .creatorDetail(userId: creator.id)) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .padding(.top, 13.3) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     ContentMainTabHomeRankCreatorView( | ||||
|         response: GetExplorerSectionResponse( | ||||
|             title: "인기 크리에이터", | ||||
|             coloredTitle: "인기", | ||||
|             color: "ff5c49", | ||||
|             desc: "2025년 02월 10일 ~ 02월 16일", | ||||
|             creators: [ | ||||
|                 GetExplorerSectionCreatorResponse( | ||||
|                     id: 1, | ||||
|                     nickname: "User1", | ||||
|                     tags: "", | ||||
|                     profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||
|                 ), | ||||
|                 GetExplorerSectionCreatorResponse( | ||||
|                     id: 2, | ||||
|                     nickname: "User2", | ||||
|                     tags: "", | ||||
|                     profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||
|                 ), | ||||
|                 GetExplorerSectionCreatorResponse( | ||||
|                     id: 3, | ||||
|                     nickname: "User3", | ||||
|                     tags: "", | ||||
|                     profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||
|                 ), | ||||
|                 GetExplorerSectionCreatorResponse( | ||||
|                     id: 4, | ||||
|                     nickname: "User4", | ||||
|                     tags: "", | ||||
|                     profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||
|                 ) | ||||
|             ] | ||||
|         ) | ||||
|     ) | ||||
| } | ||||
| @@ -0,0 +1,82 @@ | ||||
| // | ||||
| //  ContentMainTabHomeRankSeriesView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2/20/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct ContentMainTabHomeRankSeriesView: View { | ||||
|      | ||||
|     let seriesList: [SeriesListItem] | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading, spacing: 13.3) { | ||||
|             Text("인기 시리즈") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                 .foregroundColor(Color.grayee) | ||||
|              | ||||
|             ScrollView(.horizontal, showsIndicators: false) { | ||||
|                 HStack(alignment: .top, spacing: 13.3) { | ||||
|                     ForEach(0..<seriesList.count, id: \.self) { | ||||
|                         let item = seriesList[$0] | ||||
|                         SeriesListBigItemView(item: item, isVisibleCreator: true) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     ContentMainTabHomeRankSeriesView( | ||||
|         seriesList: [ | ||||
|             SeriesListItem( | ||||
|                 seriesId: 1, | ||||
|                 title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)", | ||||
|                 coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 publishedDaysOfWeek: "매주 수, 토요일", | ||||
|                 isComplete: true, | ||||
|                 creator: SeriesListItemCreator( | ||||
|                     creatorId: 1, | ||||
|                     nickname: "creator", | ||||
|                     profileImage: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||
|                 ), | ||||
|                 numberOfContent: 10, | ||||
|                 isNew: true, | ||||
|                 isPopular: true | ||||
|             ), | ||||
|             SeriesListItem( | ||||
|                 seriesId: 2, | ||||
|                 title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)", | ||||
|                 coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 publishedDaysOfWeek: "매주 수, 토요일", | ||||
|                 isComplete: false, | ||||
|                 creator: SeriesListItemCreator( | ||||
|                     creatorId: 1, | ||||
|                     nickname: "creator", | ||||
|                     profileImage: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||
|                 ), | ||||
|                 numberOfContent: 10, | ||||
|                 isNew: false, | ||||
|                 isPopular: true | ||||
|             ), | ||||
|             SeriesListItem( | ||||
|                 seriesId: 1, | ||||
|                 title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)", | ||||
|                 coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 publishedDaysOfWeek: "매주 수, 토요일", | ||||
|                 isComplete: false, | ||||
|                 creator: SeriesListItemCreator( | ||||
|                     creatorId: 1, | ||||
|                     nickname: "creator", | ||||
|                     profileImage: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||
|                 ), | ||||
|                 numberOfContent: 10, | ||||
|                 isNew: true, | ||||
|                 isPopular: false | ||||
|             ) | ||||
|         ] | ||||
|     ) | ||||
| } | ||||
| @@ -31,6 +31,7 @@ struct SectionEventBannerView: View { | ||||
|                                     ) | ||||
|                                 ) | ||||
|                                 .resizable() | ||||
|                                 .scaledToFill() | ||||
|                                 .frame(width: proxy.size.width, height: proxy.size.height, alignment: .center) | ||||
|                                 .tag(index) | ||||
|                                 .onTapGesture { | ||||
| @@ -50,6 +51,7 @@ struct SectionEventBannerView: View { | ||||
|                                     ) | ||||
|                                 ) | ||||
|                                 .resizable() | ||||
|                                 .scaledToFill() | ||||
|                                 .frame(width: proxy.size.width, height: proxy.size.height, alignment: .center) | ||||
|                                 .tag(index) | ||||
|                                 .onTapGesture { | ||||
|   | ||||
| @@ -23,7 +23,7 @@ struct HomeView: View { | ||||
|     private let liveView = LiveView() | ||||
|     private let audition = AuditionView() | ||||
|     private let messageView = MessageView() | ||||
|     private let contentView = ContentMainView() | ||||
|     private let contentView = ContentMainTabHomeView() | ||||
|      | ||||
|     @State private var isShowPlayer = false | ||||
|      | ||||
|   | ||||
| @@ -62,7 +62,7 @@ struct SeriesListBigItemView: View { | ||||
|             Text(item.title) | ||||
|                 .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                 .foregroundColor(Color.grayee) | ||||
|                 .lineLimit(2) | ||||
|                 .lineLimit(1) | ||||
|              | ||||
|             if isVisibleCreator { | ||||
|                 HStack(spacing: 3) { | ||||
|   | ||||
 Yu Sung
					Yu Sung