검색 UI 추가
This commit is contained in:
		| @@ -142,7 +142,7 @@ enum AppStep { | ||||
|      | ||||
|     case auditionRoleDetail(roleId: Int, auditionTitle: String) | ||||
|      | ||||
|     case searchChannel | ||||
|     case search | ||||
|      | ||||
|     case contentMain(startTab: ContentMainTab) | ||||
|      | ||||
|   | ||||
| @@ -61,7 +61,7 @@ struct ContentMainView: View { | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                         .onTapGesture { | ||||
|                             UserDefaults.set("", forKey: .searchChannel) | ||||
|                             AppState.shared.setAppStep(step: .searchChannel) | ||||
|                             AppState.shared.setAppStep(step: .search) | ||||
|                         } | ||||
|                          | ||||
|                         ContentMainCreatorRankingView() | ||||
|   | ||||
| @@ -81,7 +81,7 @@ struct ContentMainTabHomeView: View { | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                             .onTapGesture { | ||||
|                                 UserDefaults.set("", forKey: .searchChannel) | ||||
|                                 AppState.shared.setAppStep(step: .searchChannel) | ||||
|                                 AppState.shared.setAppStep(step: .search) | ||||
|                             } | ||||
|                              | ||||
|                             VStack(spacing: 13.3) { | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import Kingfisher | ||||
|  | ||||
| struct SeriesDetailView: View { | ||||
|      | ||||
|     @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode> | ||||
|     @ObservedObject var viewModel = SeriesDetailViewModel() | ||||
|      | ||||
|     let seriesId: Int | ||||
| @@ -35,7 +36,13 @@ struct SeriesDetailView: View { | ||||
|                                 Image("ic_back") | ||||
|                                     .resizable() | ||||
|                                     .frame(width: 20, height: 20) | ||||
|                                     .onTapGesture { AppState.shared.back() } | ||||
|                                     .onTapGesture { | ||||
|                                         if presentationMode.wrappedValue.isPresented { | ||||
|                                             presentationMode.wrappedValue.dismiss() | ||||
|                                         } else { | ||||
|                                             AppState.shared.back() | ||||
|                                         } | ||||
|                                     } | ||||
|                                  | ||||
|                                 Spacer() | ||||
|                             } | ||||
| @@ -232,6 +239,8 @@ struct SeriesDetailView: View { | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .navigationTitle("") | ||||
|             .navigationBarBackButtonHidden() | ||||
|         } | ||||
|         .onAppear { | ||||
|             viewModel.seriesId = seriesId | ||||
|   | ||||
| @@ -215,8 +215,8 @@ struct ContentView: View { | ||||
|                     auditionTitle: auditionTitle | ||||
|                 ) | ||||
|                  | ||||
|             case .searchChannel: | ||||
|                 SearchChannelView() | ||||
|             case .search: | ||||
|                 SearchView() | ||||
|                  | ||||
|             case .contentMain(let startTab): | ||||
|                 ContentMainViewV2(selectedTab: startTab) | ||||
|   | ||||
| @@ -37,7 +37,7 @@ struct FocusedTextField: UIViewRepresentable { | ||||
|         textField.autocapitalizationType = .none | ||||
|         textField.autocorrectionType = .no | ||||
|          | ||||
|         let placeholder = "채널명을 입력해 보세요" | ||||
|         let placeholder = "검색" | ||||
|         textField.placeholder = placeholder | ||||
|         textField.attributedPlaceholder = NSAttributedString( | ||||
|             string: placeholder, | ||||
|   | ||||
| @@ -10,6 +10,8 @@ import SwiftUI | ||||
| struct UserProfileView: View { | ||||
|      | ||||
|     let userId: Int | ||||
|      | ||||
|     @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode> | ||||
|     @StateObject var viewModel = UserProfileViewModel() | ||||
|      | ||||
|     @State private var memberId: Int = 0 | ||||
| @@ -24,7 +26,11 @@ struct UserProfileView: View { | ||||
|                 VStack(spacing: 0) { | ||||
|                     HStack(spacing: 0) { | ||||
|                         Button { | ||||
|                             AppState.shared.back() | ||||
|                             if presentationMode.wrappedValue.isPresented { | ||||
|                                 presentationMode.wrappedValue.dismiss() | ||||
|                             } else { | ||||
|                                 AppState.shared.back() | ||||
|                             } | ||||
|                         } label: { | ||||
|                             Image("ic_back") | ||||
|                                 .resizable() | ||||
| @@ -250,6 +256,8 @@ struct UserProfileView: View { | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 .navigationTitle("") | ||||
|                 .navigationBarBackButtonHidden() | ||||
|                 .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) { | ||||
|                     HStack { | ||||
|                         Spacer() | ||||
|   | ||||
							
								
								
									
										92
									
								
								SodaLive/Sources/Search/SearchApi.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								SodaLive/Sources/Search/SearchApi.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| // | ||||
| //  SearchApi.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 3/27/25. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import Moya | ||||
|  | ||||
| enum SearchApi { | ||||
|     case searchUnified(keyword: String, isAdultContentVisible: Bool, contentType: ContentType) | ||||
|     case searchCreatorList(keyword: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) | ||||
|     case searchContentList(keyword: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) | ||||
|     case searchSeriesList(keyword: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) | ||||
| } | ||||
|  | ||||
| extension SearchApi: TargetType { | ||||
|     var baseURL: URL { | ||||
|         return URL(string: BASE_URL)! | ||||
|     } | ||||
|      | ||||
|     var path: String { | ||||
|         switch self { | ||||
|         case .searchUnified: | ||||
|             return "/search" | ||||
|              | ||||
|         case .searchCreatorList: | ||||
|             return "/search/creators" | ||||
|              | ||||
|         case .searchContentList: | ||||
|             return "/search/contents" | ||||
|              | ||||
|         case .searchSeriesList: | ||||
|             return "/search/series" | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     var method: Moya.Method { | ||||
|         return .get | ||||
|     } | ||||
|      | ||||
|     var task: Moya.Task { | ||||
|         switch self { | ||||
|         case .searchUnified(let keyword, let isAdultContentVisible, let contentType): | ||||
|             let parameters = [ | ||||
|                 "keyword": keyword, | ||||
|                 "isAdultContentVisible": isAdultContentVisible, | ||||
|                 "contentType": contentType | ||||
|             ] as [String : Any] | ||||
|              | ||||
|             return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) | ||||
|              | ||||
|         case .searchCreatorList(let keyword, let isAdultContentVisible, let contentType, let page, let size): | ||||
|             let parameters = [ | ||||
|                 "keyword": keyword, | ||||
|                 "isAdultContentVisible": isAdultContentVisible, | ||||
|                 "contentType": contentType, | ||||
|                 "page": page - 1, | ||||
|                 "size": size | ||||
|             ] as [String : Any] | ||||
|              | ||||
|             return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) | ||||
|              | ||||
|         case .searchContentList(let keyword, let isAdultContentVisible, let contentType, let page, let size): | ||||
|             let parameters = [ | ||||
|                 "keyword": keyword, | ||||
|                 "isAdultContentVisible": isAdultContentVisible, | ||||
|                 "contentType": contentType, | ||||
|                 "page": page - 1, | ||||
|                 "size": size | ||||
|             ] as [String : Any] | ||||
|              | ||||
|             return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) | ||||
|              | ||||
|         case .searchSeriesList(let keyword, let isAdultContentVisible, let contentType, let page, let size): | ||||
|             let parameters = [ | ||||
|                 "keyword": keyword, | ||||
|                 "isAdultContentVisible": isAdultContentVisible, | ||||
|                 "contentType": contentType, | ||||
|                 "page": page - 1, | ||||
|                 "size": size | ||||
|             ] as [String : Any] | ||||
|              | ||||
|             return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     var headers: [String : String]? { | ||||
|         return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] | ||||
|     } | ||||
| } | ||||
							
								
								
									
										45
									
								
								SodaLive/Sources/Search/SearchContentListView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								SodaLive/Sources/Search/SearchContentListView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| // | ||||
| //  SearchContentListView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 3/27/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct SearchContentListView: View { | ||||
|      | ||||
|     let itemsList: [SearchResponseItem] | ||||
|      | ||||
|     var body: some View { | ||||
|         ScrollView(.vertical, showsIndicators: false) { | ||||
|             VStack(spacing: 13.3) { | ||||
|                 ForEach(0..<itemsList.count, id: \.self) { | ||||
|                     SearchContentItemView(item: itemsList[$0]) | ||||
|                 } | ||||
|             } | ||||
|             .padding(.horizontal, 13.3) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     SearchContentListView( | ||||
|         itemsList: [ | ||||
|             SearchResponseItem( | ||||
|                 id: 1, | ||||
|                 imageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 title: "Title1", | ||||
|                 nickname: "Tester1", | ||||
|                 type: .CONTENT | ||||
|             ), | ||||
|             SearchResponseItem( | ||||
|                 id: 2, | ||||
|                 imageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 title: "Title2", | ||||
|                 nickname: "Tester2", | ||||
|                 type: .CONTENT | ||||
|             ) | ||||
|         ] | ||||
|     ) | ||||
| } | ||||
							
								
								
									
										45
									
								
								SodaLive/Sources/Search/SearchCreatorListView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								SodaLive/Sources/Search/SearchCreatorListView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| // | ||||
| //  SearchCreatorListView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 3/27/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct SearchCreatorListView: View { | ||||
|      | ||||
|     let itemsList: [SearchResponseItem] | ||||
|      | ||||
|     var body: some View { | ||||
|         ScrollView(.vertical, showsIndicators: false) { | ||||
|             VStack(spacing: 13.3) { | ||||
|                 ForEach(0..<itemsList.count, id: \.self) { | ||||
|                     SearchCreatorItemView(item: itemsList[$0]) | ||||
|                 } | ||||
|             } | ||||
|             .padding(.horizontal, 13.3) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     SearchCreatorListView( | ||||
|         itemsList: [ | ||||
|             SearchResponseItem( | ||||
|                 id: 1, | ||||
|                 imageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 title: "Tester", | ||||
|                 nickname: "Tester", | ||||
|                 type: .CREATOR | ||||
|             ), | ||||
|             SearchResponseItem( | ||||
|                 id: 2, | ||||
|                 imageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 title: "Tester2", | ||||
|                 nickname: "Tester2", | ||||
|                 type: .CREATOR | ||||
|             ) | ||||
|         ] | ||||
|     ) | ||||
| } | ||||
							
								
								
									
										61
									
								
								SodaLive/Sources/Search/SearchRepository.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								SodaLive/Sources/Search/SearchRepository.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| // | ||||
| //  SearchRepository.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 3/27/25. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import CombineMoya | ||||
| import Combine | ||||
| import Moya | ||||
|  | ||||
| final class SearchRepository { | ||||
|     private let api = MoyaProvider<SearchApi>() | ||||
|      | ||||
|     func searchUnified(keyword: String) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher( | ||||
|             .searchUnified( | ||||
|                 keyword: keyword, | ||||
|                 isAdultContentVisible: UserDefaults.isAdultContentVisible(), | ||||
|                 contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
|      | ||||
|     func searchCreatorList(keyword: String, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher( | ||||
|             .searchCreatorList( | ||||
|                 keyword: keyword, | ||||
|                 isAdultContentVisible: UserDefaults.isAdultContentVisible(), | ||||
|                 contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL, | ||||
|                 page: page, | ||||
|                 size: size | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
|      | ||||
|     func searchContentList(keyword: String, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher( | ||||
|             .searchContentList( | ||||
|                 keyword: keyword, | ||||
|                 isAdultContentVisible: UserDefaults.isAdultContentVisible(), | ||||
|                 contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL, | ||||
|                 page: page, | ||||
|                 size: size | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
|      | ||||
|     func searchSeriesList(keyword: String, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher( | ||||
|             .searchSeriesList( | ||||
|                 keyword: keyword, | ||||
|                 isAdultContentVisible: UserDefaults.isAdultContentVisible(), | ||||
|                 contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL, | ||||
|                 page: page, | ||||
|                 size: size | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										29
									
								
								SodaLive/Sources/Search/SearchResponse.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								SodaLive/Sources/Search/SearchResponse.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| // | ||||
| //  SearchResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 3/27/25. | ||||
| // | ||||
|  | ||||
| struct SearchUnifiedResponse: Decodable { | ||||
|     let creatorList: [SearchResponseItem] | ||||
|     let contentList: [SearchResponseItem] | ||||
|     let seriesList: [SearchResponseItem] | ||||
| } | ||||
|  | ||||
| struct SearchResponse: Decodable { | ||||
|     let totalCount: Int | ||||
|     let items: [SearchResponseItem] | ||||
| } | ||||
|  | ||||
| struct SearchResponseItem: Decodable { | ||||
|     let id: Int | ||||
|     let imageUrl: String | ||||
|     let title: String | ||||
|     let nickname: String | ||||
|     let type: SearchResponseType | ||||
| } | ||||
|  | ||||
| enum SearchResponseType: String, Decodable { | ||||
|     case CREATOR, CONTENT, SERIES | ||||
| } | ||||
							
								
								
									
										44
									
								
								SodaLive/Sources/Search/SearchSeriesListView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								SodaLive/Sources/Search/SearchSeriesListView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| // | ||||
| //  SearchSeriesListView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 3/27/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct SearchSeriesListView: View { | ||||
|      | ||||
|     let itemsList: [SearchResponseItem] | ||||
|      | ||||
|     var body: some View { | ||||
|         ScrollView(.vertical, showsIndicators: false) { | ||||
|             VStack(spacing: 13.3) { | ||||
|                 ForEach(0..<itemsList.count, id: \.self) { | ||||
|                     SearchSeriesItemView(item: itemsList[$0]) | ||||
|                 } | ||||
|             } | ||||
|             .padding(.horizontal, 13.3) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     SearchSeriesListView( | ||||
|         itemsList: [ | ||||
|             SearchResponseItem( | ||||
|                 id: 1, | ||||
|                 imageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 title: "Title1", | ||||
|                 nickname: "Tester1", | ||||
|                 type: .SERIES | ||||
|             ), | ||||
|             SearchResponseItem( | ||||
|                 id: 2, | ||||
|                 imageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 title: "Title2", | ||||
|                 nickname: "Tester2", | ||||
|                 type: .SERIES | ||||
|             ) | ||||
|         ]) | ||||
| } | ||||
							
								
								
									
										239
									
								
								SodaLive/Sources/Search/SearchUnifiedView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										239
									
								
								SodaLive/Sources/Search/SearchUnifiedView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,239 @@ | ||||
| // | ||||
| //  SearchUnifiedView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 3/27/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct SearchUnifiedView: View { | ||||
|      | ||||
|     let creatorList: [SearchResponseItem] | ||||
|     let contentList: [SearchResponseItem] | ||||
|     let searchList: [SearchResponseItem] | ||||
|      | ||||
|     let onTapMoreCreator: () -> Void | ||||
|     let onTapMoreContent: () -> Void | ||||
|     let onTapMoreSeries: () -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         ScrollView(.vertical, showsIndicators: false) { | ||||
|             VStack(spacing: 30) { | ||||
|                 if !creatorList.isEmpty { | ||||
|                     SearchUnifiedItemView( | ||||
|                         title: "채널", | ||||
|                         itemList: creatorList, | ||||
|                         onTapMore: onTapMoreCreator | ||||
|                     ) | ||||
|                     .frame(maxWidth: .infinity) | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                 } | ||||
|                  | ||||
|                 if !contentList.isEmpty { | ||||
|                     SearchUnifiedItemView( | ||||
|                         title: "콘텐츠", | ||||
|                         itemList: contentList, | ||||
|                         onTapMore: onTapMoreContent | ||||
|                     ) | ||||
|                     .frame(maxWidth: .infinity) | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                 } | ||||
|                  | ||||
|                 if !searchList.isEmpty { | ||||
|                     SearchUnifiedItemView( | ||||
|                         title: "시리즈", | ||||
|                         itemList: searchList, | ||||
|                         onTapMore: onTapMoreSeries | ||||
|                     ) | ||||
|                     .frame(maxWidth: .infinity) | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct SearchUnifiedItemView: View { | ||||
|     let title: String | ||||
|     let itemList: [SearchResponseItem] | ||||
|     let onTapMore: () -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading, spacing: 13.3) { | ||||
|             Text(title) | ||||
|                 .font(.custom(Font.bold.rawValue, size: 16)) | ||||
|                 .foregroundColor(.grayee) | ||||
|              | ||||
|             ForEach(0..<itemList.count, id: \.self) { | ||||
|                 let item = itemList[$0] | ||||
|                  | ||||
|                 switch item.type { | ||||
|                 case .CREATOR: | ||||
|                     SearchCreatorItemView(item: item) | ||||
|                      | ||||
|                 case .CONTENT: | ||||
|                     SearchContentItemView(item: item) | ||||
|                      | ||||
|                 case .SERIES: | ||||
|                     SearchSeriesItemView(item: item) | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             Text("더보기 >") | ||||
|                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                 .foregroundColor(.grayee) | ||||
|                 .padding(.vertical, 10) | ||||
|                 .frame(maxWidth: .infinity) | ||||
|                 .background(Color.gray33.opacity(0.7)) | ||||
|                 .onTapGesture { onTapMore() } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct SearchCreatorItemView: View { | ||||
|     let item: SearchResponseItem | ||||
|      | ||||
|     var body: some View { | ||||
|         NavigationLink { | ||||
|             UserProfileView(userId: item.id) | ||||
|         } label: { | ||||
|             HStack(spacing: 13.3) { | ||||
|                 KFImage(URL(string: item.imageUrl)) | ||||
|                     .cancelOnDisappear(true) | ||||
|                     .downsampling(size: CGSize(width: 60, height: 60)) | ||||
|                     .resizable() | ||||
|                     .frame(width: 60, height: 60) | ||||
|                     .clipShape(Circle()) | ||||
|                  | ||||
|                 Text(item.nickname) | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color.grayee) | ||||
|                  | ||||
|                 Spacer() | ||||
|             } | ||||
|             .frame(maxWidth: .infinity) | ||||
|             .contentShape(Rectangle()) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct SearchContentItemView: View { | ||||
|     let item: SearchResponseItem | ||||
|      | ||||
|     var body: some View { | ||||
|         NavigationLink { | ||||
|             ContentDetailView(contentId: item.id) | ||||
|         } label: { | ||||
|             HStack(spacing: 13.3) { | ||||
|                 KFImage(URL(string: item.imageUrl)) | ||||
|                     .cancelOnDisappear(true) | ||||
|                     .downsampling(size: CGSize(width: 60, height: 60)) | ||||
|                     .resizable() | ||||
|                     .frame(width: 60, height: 60) | ||||
|                     .cornerRadius(5.3) | ||||
|                  | ||||
|                 VStack(alignment: .leading, spacing: 6.7) { | ||||
|                     Text(item.title) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                         .foregroundColor(Color.grayee) | ||||
|                      | ||||
|                     Text(item.nickname) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                         .foregroundColor(Color.gray77) | ||||
|                 } | ||||
|                  | ||||
|                 Spacer() | ||||
|             } | ||||
|             .frame(maxWidth: .infinity) | ||||
|             .contentShape(Rectangle()) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct SearchSeriesItemView: View { | ||||
|     let item: SearchResponseItem | ||||
|      | ||||
|     var body: some View { | ||||
|         NavigationLink { | ||||
|             SeriesDetailView(seriesId: item.id) | ||||
|         } label: { | ||||
|             HStack(spacing: 13.3) { | ||||
|                 KFImage(URL(string: item.imageUrl)) | ||||
|                     .cancelOnDisappear(true) | ||||
|                     .downsampling(size: CGSize(width: 60, height: 85)) | ||||
|                     .resizable() | ||||
|                     .scaledToFill() | ||||
|                     .frame(width: 60, height: 85) | ||||
|                     .clipped() | ||||
|                     .cornerRadius(5.3) | ||||
|                  | ||||
|                 VStack(alignment: .leading, spacing: 6.7) { | ||||
|                     Text(item.title) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                         .foregroundColor(Color.grayee) | ||||
|                      | ||||
|                     Text(item.nickname) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                         .foregroundColor(Color.gray77) | ||||
|                 } | ||||
|                  | ||||
|                 Spacer() | ||||
|             } | ||||
|             .frame(maxWidth: .infinity) | ||||
|             .contentShape(Rectangle()) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| #Preview { | ||||
|     SearchUnifiedView( | ||||
|         creatorList: [ | ||||
|             SearchResponseItem( | ||||
|                 id: 1, | ||||
|                 imageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 title: "Tester", | ||||
|                 nickname: "Tester", | ||||
|                 type: .CREATOR | ||||
|             ) | ||||
|         ], | ||||
|         contentList: [ | ||||
|             SearchResponseItem( | ||||
|                 id: 1, | ||||
|                 imageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 title: "Title1", | ||||
|                 nickname: "Tester", | ||||
|                 type: .CONTENT | ||||
|             ), | ||||
|              | ||||
|             SearchResponseItem( | ||||
|                 id: 2, | ||||
|                 imageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 title: "Title2", | ||||
|                 nickname: "Tester2", | ||||
|                 type: .CONTENT | ||||
|             ) | ||||
|         ], | ||||
|         searchList: [ | ||||
|             SearchResponseItem( | ||||
|                 id: 1, | ||||
|                 imageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 title: "Title1", | ||||
|                 nickname: "Tester", | ||||
|                 type: .SERIES | ||||
|             ), | ||||
|              | ||||
|             SearchResponseItem( | ||||
|                 id: 2, | ||||
|                 imageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 title: "Title2", | ||||
|                 nickname: "Tester2", | ||||
|                 type: .SERIES | ||||
|             )], | ||||
|         onTapMoreCreator: {}, | ||||
|         onTapMoreContent: {}, | ||||
|         onTapMoreSeries: {} | ||||
|     ) | ||||
| } | ||||
							
								
								
									
										165
									
								
								SodaLive/Sources/Search/SearchView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								SodaLive/Sources/Search/SearchView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,165 @@ | ||||
| // | ||||
| //  SearchView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 3/27/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct SearchViewTabItem { | ||||
|     let title: String | ||||
|     let tab: SearchViewModel.CurrentTab | ||||
| } | ||||
|  | ||||
| struct SearchView: View { | ||||
|      | ||||
|     @StateObject var viewModel = SearchViewModel() | ||||
|     @State private var isFocused: Bool = false | ||||
|      | ||||
|     let tabItemList = [ | ||||
|         SearchViewTabItem(title: "통합", tab: .UNIFIED), | ||||
|         SearchViewTabItem(title: "채널", tab: .CREATOR), | ||||
|         SearchViewTabItem(title: "콘텐츠", tab: .CONTENT), | ||||
|         SearchViewTabItem(title: "시리즈", tab: .SERIES) | ||||
|     ] | ||||
|      | ||||
|     var body: some View { | ||||
|         NavigationView { | ||||
|             BaseView(isLoading: $viewModel.isLoading) { | ||||
|                 VStack(spacing: 0) { | ||||
|                     HStack(spacing: 0) { | ||||
|                         Button { | ||||
|                             AppState.shared.back() | ||||
|                         } label: { | ||||
|                             Image("ic_back") | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 20, height: 20) | ||||
|                         } | ||||
|                         .padding(13.3) | ||||
|                          | ||||
|                         HStack(spacing: 0) { | ||||
|                             Image("ic_title_search_black") | ||||
|                              | ||||
|                             FocusedTextField( | ||||
|                                 text: $viewModel.keyword, | ||||
|                                 isFirstResponder: isFocused | ||||
|                             ) | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                             .onAppear { | ||||
|                                 DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { | ||||
|                                     isFocused = true | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         .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(.trailing, 13.3) | ||||
|                      | ||||
|                     if !viewModel.searchUnifiedCreatorList.isEmpty || | ||||
|                         !viewModel.searchUnifiedContentList.isEmpty || | ||||
|                         !viewModel.searchUnifiedSeriesList.isEmpty { | ||||
|                         ScrollView(.horizontal, showsIndicators: false) { | ||||
|                             HStack(spacing: 8) { | ||||
|                                 ForEach(0..<tabItemList.count, id: \.self) { index in | ||||
|                                     let tabItem = tabItemList[index] | ||||
|                                      | ||||
|                                     Text(tabItem.title) | ||||
|                                         .font(.custom(Font.medium.rawValue,size: 16)) | ||||
|                                         .foregroundColor( | ||||
|                                             viewModel.currentTab == tabItem.tab ? | ||||
|                                                 .button : | ||||
|                                                     .graybb | ||||
|                                         ) | ||||
|                                         .padding(.horizontal, 12) | ||||
|                                         .onTapGesture { | ||||
|                                             if viewModel.currentTab != tabItem.tab { | ||||
|                                                 viewModel.currentTab = tabItem.tab | ||||
|                                             } | ||||
|                                         } | ||||
|                                 } | ||||
|                             } | ||||
|                             .padding(.vertical, 15) | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                         } | ||||
|                         .padding(.bottom, 13.3) | ||||
|                          | ||||
|                         switch viewModel.currentTab { | ||||
|                         case .UNIFIED: | ||||
|                             SearchUnifiedView( | ||||
|                                 creatorList: viewModel.searchUnifiedCreatorList, | ||||
|                                 contentList: viewModel.searchUnifiedContentList, | ||||
|                                 searchList: viewModel.searchUnifiedSeriesList, | ||||
|                                 onTapMoreCreator: { | ||||
|                                     viewModel.currentTab = .CREATOR | ||||
|                                 }, | ||||
|                                 onTapMoreContent: { | ||||
|                                     viewModel.currentTab = .CONTENT | ||||
|                                 }, | ||||
|                                 onTapMoreSeries: { | ||||
|                                     viewModel.currentTab = .SERIES | ||||
|                                 } | ||||
|                             ) | ||||
|                              | ||||
|                         case .CREATOR: | ||||
|                             SearchCreatorListView(itemsList: viewModel.searchCreatorItemList) | ||||
|                              | ||||
|                         case .CONTENT: | ||||
|                             SearchContentListView(itemsList: viewModel.searchContentItemList) | ||||
|                              | ||||
|                         case .SERIES: | ||||
|                             SearchSeriesListView(itemsList: viewModel.searchSeriesItemList) | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|                     if viewModel.searchUnifiedCreatorList.isEmpty && | ||||
|                         viewModel.searchUnifiedContentList.isEmpty && | ||||
|                         viewModel.searchUnifiedSeriesList.isEmpty && | ||||
|                         viewModel.keyword.count > 2 { | ||||
|                         Text("검색 결과가 없습니다.") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 18.3)) | ||||
|                             .foregroundColor(.white) | ||||
|                             .padding(.top, 20) | ||||
|                     } | ||||
|                      | ||||
|                     Spacer() | ||||
|                 } | ||||
|                 .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { | ||||
|                     GeometryReader { geo in | ||||
|                         HStack { | ||||
|                             Spacer() | ||||
|                             Text(viewModel.errorMessage) | ||||
|                                 .padding(.vertical, 13.3) | ||||
|                                 .frame(width: geo.size.width - 66.7, alignment: .center) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                                 .background(Color.button) | ||||
|                                 .foregroundColor(Color.white) | ||||
|                                 .multilineTextAlignment(.center) | ||||
|                                 .cornerRadius(20) | ||||
|                                 .padding(.top, 66.7) | ||||
|                             Spacer() | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 .onAppear { | ||||
|                     if viewModel.keyword.isEmpty { | ||||
|                         viewModel.keyword = UserDefaults.string(forKey: .searchChannel) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     SearchView() | ||||
| } | ||||
							
								
								
									
										278
									
								
								SodaLive/Sources/Search/SearchViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										278
									
								
								SodaLive/Sources/Search/SearchViewModel.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,278 @@ | ||||
| // | ||||
| //  SearchViewModel.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 3/27/25. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import Combine | ||||
|  | ||||
| final class SearchViewModel: ObservableObject { | ||||
|     enum CurrentTab: String { | ||||
|         case UNIFIED, CREATOR, CONTENT, SERIES | ||||
|     } | ||||
|      | ||||
|     private let repository = SearchRepository() | ||||
|     private var subscription = Set<AnyCancellable>() | ||||
|      | ||||
|     @Published var currentTab: CurrentTab = .UNIFIED { | ||||
|         didSet { | ||||
|             if currentTab == .CREATOR && searchCreatorItemList.isEmpty { | ||||
|                 self.searchCreatorList() | ||||
|             } else if currentTab == .CONTENT && searchContentItemList.isEmpty { | ||||
|                 self.searchContentList() | ||||
|             } else if currentTab == .SERIES && searchSeriesItemList.isEmpty { | ||||
|                 self.searchSeriesList() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @Published var errorMessage = "" | ||||
|     @Published var isShowPopup = false | ||||
|     @Published var isLoading = false | ||||
|      | ||||
|     @Published var keyword = "" | ||||
|      | ||||
|     @Published var searchUnifiedCreatorList: [SearchResponseItem] = [] | ||||
|     @Published var searchUnifiedContentList: [SearchResponseItem] = [] | ||||
|     @Published var searchUnifiedSeriesList: [SearchResponseItem] = [] | ||||
|      | ||||
|     @Published var searchCreatorItemList: [SearchResponseItem] = [] | ||||
|     @Published var searchContentItemList: [SearchResponseItem] = [] | ||||
|     @Published var searchSeriesItemList: [SearchResponseItem] = [] | ||||
|      | ||||
|     var searchCreatorPage = 1 | ||||
|     var searchContentPage = 1 | ||||
|     var searchSeriesPage = 1 | ||||
|      | ||||
|     var isSearchCreatorLast = false | ||||
|     var isSearchContentLast = false | ||||
|     var isSearchSeriesLast = false | ||||
|      | ||||
|     private var pageSize = 20 | ||||
|      | ||||
|     init() { | ||||
|         _keyword = Published(initialValue: "") | ||||
|         $keyword | ||||
|             .debounce(for: .seconds(0.3), scheduler: RunLoop.main) | ||||
|             .sink { [unowned self] value in | ||||
|                 UserDefaults.set(value, forKey: .searchChannel) | ||||
|                 if value.count > 1 { | ||||
|                     self.searchUnified() | ||||
|                 } else { | ||||
|                     self.initList() | ||||
|                 } | ||||
|             } | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
|      | ||||
|     func initList() { | ||||
|         searchCreatorPage = 1 | ||||
|         searchContentPage = 1 | ||||
|         searchSeriesPage = 1 | ||||
|          | ||||
|         isSearchCreatorLast = false | ||||
|         isSearchContentLast = false | ||||
|         isSearchSeriesLast = false | ||||
|          | ||||
|         searchUnifiedCreatorList.removeAll() | ||||
|         searchUnifiedContentList.removeAll() | ||||
|         searchUnifiedSeriesList.removeAll() | ||||
|          | ||||
|         searchCreatorItemList.removeAll() | ||||
|         searchContentItemList.removeAll() | ||||
|         searchSeriesItemList.removeAll() | ||||
|     } | ||||
|      | ||||
|     func searchUnified() { | ||||
|         if !isLoading { | ||||
|             if currentTab != .UNIFIED { | ||||
|                 currentTab = .UNIFIED | ||||
|             } | ||||
|              | ||||
|             initList() | ||||
|              | ||||
|             isLoading = true | ||||
|              | ||||
|             repository.searchUnified(keyword: keyword) | ||||
|                 .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<SearchUnifiedResponse>.self, from: responseData) | ||||
|                          | ||||
|                         if let data = decoded.data, decoded.success { | ||||
|                             DEBUG_LOG("test: \(data)") | ||||
|                             searchUnifiedCreatorList.append(contentsOf: data.creatorList) | ||||
|                             searchUnifiedContentList.append(contentsOf: data.contentList) | ||||
|                             searchUnifiedSeriesList.append(contentsOf: data.seriesList) | ||||
|                         } else { | ||||
|                             if let message = decoded.message { | ||||
|                                 self.errorMessage = message | ||||
|                             } else { | ||||
|                                 self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                             } | ||||
|                              | ||||
|                             self.isShowPopup = true | ||||
|                         } | ||||
|                     } catch { | ||||
|                         DEBUG_LOG("error: \(error)") | ||||
|                         self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                 } | ||||
|                 .store(in: &subscription) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func searchCreatorList() { | ||||
|         if !isLoading && !isSearchCreatorLast { | ||||
|             isLoading = true | ||||
|              | ||||
|             repository.searchCreatorList(keyword: keyword, page: searchCreatorPage, 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<SearchResponse>.self, from: responseData) | ||||
|                          | ||||
|                         if let data = decoded.data, decoded.success { | ||||
|                             DEBUG_LOG("test: \(data)") | ||||
|                             self.searchCreatorPage += 1 | ||||
|                             self.searchCreatorItemList.append(contentsOf: data.items) | ||||
|                              | ||||
|                             if data.items.isEmpty { | ||||
|                                 self.isSearchCreatorLast = true | ||||
|                             } | ||||
|                         } else { | ||||
|                             if let message = decoded.message { | ||||
|                                 self.errorMessage = message | ||||
|                             } else { | ||||
|                                 self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                             } | ||||
|                              | ||||
|                             self.isShowPopup = true | ||||
|                         } | ||||
|                     } catch { | ||||
|                         ERROR_LOG("test: \(error)") | ||||
|                         self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                 } | ||||
|                 .store(in: &subscription) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func searchContentList() { | ||||
|         if !isLoading && !isSearchContentLast { | ||||
|             isLoading = true | ||||
|              | ||||
|             repository.searchContentList(keyword: keyword, page: searchContentPage, 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<SearchResponse>.self, from: responseData) | ||||
|                          | ||||
|                         if let data = decoded.data, decoded.success { | ||||
|                             DEBUG_LOG("test: \(data)") | ||||
|                             self.searchContentPage += 1 | ||||
|                             self.searchContentItemList.append(contentsOf: data.items) | ||||
|                              | ||||
|                             if data.items.isEmpty { | ||||
|                                 self.isSearchContentLast = true | ||||
|                             } | ||||
|                         } else { | ||||
|                             if let message = decoded.message { | ||||
|                                 self.errorMessage = message | ||||
|                             } else { | ||||
|                                 self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                             } | ||||
|                              | ||||
|                             self.isShowPopup = true | ||||
|                         } | ||||
|                     } catch { | ||||
|                         ERROR_LOG("test: \(error)") | ||||
|                         self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                 } | ||||
|                 .store(in: &subscription) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func searchSeriesList() { | ||||
|         if !isLoading && !isSearchSeriesLast { | ||||
|             isLoading = true | ||||
|              | ||||
|             repository.searchSeriesList(keyword: keyword, page: searchSeriesPage, 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<SearchResponse>.self, from: responseData) | ||||
|                          | ||||
|                         if let data = decoded.data, decoded.success { | ||||
|                             DEBUG_LOG("test; \(data)") | ||||
|                             self.searchSeriesPage += 1 | ||||
|                             self.searchSeriesItemList.append(contentsOf: data.items) | ||||
|                              | ||||
|                             if data.items.isEmpty { | ||||
|                                 self.isSearchSeriesLast = true | ||||
|                             } | ||||
|                         } else { | ||||
|                             if let message = decoded.message { | ||||
|                                 self.errorMessage = message | ||||
|                             } else { | ||||
|                                 self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                             } | ||||
|                              | ||||
|                             self.isShowPopup = true | ||||
|                         } | ||||
|                     } catch { | ||||
|                         ERROR_LOG("test: \(error)") | ||||
|                         self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                 } | ||||
|                 .store(in: &subscription) | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user
	 Yu Sung
					Yu Sung