diff --git a/SodaLive/Resources/Assets.xcassets/ic_close_white.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_close_white.imageset/Contents.json new file mode 100644 index 0000000..e2f3fb8 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_close_white.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_close_white.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_close_white.imageset/ic_close_white.png b/SodaLive/Resources/Assets.xcassets/ic_close_white.imageset/ic_close_white.png new file mode 100644 index 0000000..0813978 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_close_white.imageset/ic_close_white.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_title_search_black.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_title_search_black.imageset/Contents.json new file mode 100644 index 0000000..4a1c7c8 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_title_search_black.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_title_search_black.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_title_search_black.imageset/ic_title_search_black.png b/SodaLive/Resources/Assets.xcassets/ic_title_search_black.imageset/ic_title_search_black.png new file mode 100644 index 0000000..3f5220a Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_title_search_black.imageset/ic_title_search_black.png differ diff --git a/SodaLive/Sources/Explorer/ExplorerApi.swift b/SodaLive/Sources/Explorer/ExplorerApi.swift new file mode 100644 index 0000000..4a19fe8 --- /dev/null +++ b/SodaLive/Sources/Explorer/ExplorerApi.swift @@ -0,0 +1,51 @@ +// +// ExplorerApi.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Moya + +enum ExplorerApi { + case getExplorer + case searchChannel(channel: String) +} + +extension ExplorerApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getExplorer: + return "/explorer" + + case .searchChannel: + return "/explorer/search/channel" + } + } + + var method: Moya.Method { + switch self { + case .getExplorer, .searchChannel: + return .get + } + } + + var task: Task { + switch self { + case .getExplorer: + return .requestPlain + + case .searchChannel(let channel): + return .requestParameters(parameters: ["channel" : channel], encoding: URLEncoding.queryString) + } + } + + var headers: [String : String]? { + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} diff --git a/SodaLive/Sources/Explorer/ExplorerRepository.swift b/SodaLive/Sources/Explorer/ExplorerRepository.swift new file mode 100644 index 0000000..137edd4 --- /dev/null +++ b/SodaLive/Sources/Explorer/ExplorerRepository.swift @@ -0,0 +1,23 @@ +// +// ExplorerRepository.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import CombineMoya +import Combine +import Moya + +final class ExplorerRepository { + private let api = MoyaProvider() + + func getExplorer() -> AnyPublisher { + return api.requestPublisher(.getExplorer) + } + + func searchChannel(channel: String) -> AnyPublisher { + return api.requestPublisher(.searchChannel(channel: channel)) + } +} diff --git a/SodaLive/Sources/Explorer/ExplorerView.swift b/SodaLive/Sources/Explorer/ExplorerView.swift index 0ed1db9..48d5d15 100644 --- a/SodaLive/Sources/Explorer/ExplorerView.swift +++ b/SodaLive/Sources/Explorer/ExplorerView.swift @@ -6,10 +6,174 @@ // import SwiftUI +import Kingfisher struct ExplorerView: View { + + @StateObject var viewModel = ExplorerViewModel() + var body: some View { - Text("Explorer") + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + HStack(spacing: 0) { + Image("ic_title_search_black") + + TextField("채널명을 입력해 보세요", text: $viewModel.channel) + .autocapitalization(.none) + .disableAutocorrection(true) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + .accentColor(Color(hex: "9970ff")) + .keyboardType(.default) + .padding(.horizontal, 13.3) + + if !viewModel.channel.isEmpty { + Image("ic_close_white") + .resizable() + .frame(width: 20, height: 20) + .onTapGesture { + viewModel.channel = "" + } + } + } + .padding(.horizontal, 21.3) + .frame(width: screenSize().width - 26.7, height: 50) + .background(Color(hex: "222222")) + .overlay( + RoundedRectangle(cornerRadius: 6.7) + .strokeBorder(lineWidth: 1) + .foregroundColor(Color(hex: "bbbbbb")) + ) + .padding(.top, 20) + + ZStack { + if viewModel.channel.count > 1 { + ScrollView(.vertical, showsIndicators: false) { + if viewModel.channelResponses.count > 0 { + VStack(spacing: 26.7) { + ForEach(viewModel.channelResponses, id: \.self) { channel in + HStack(spacing: 13.3) { + KFImage(URL(string: channel.profileImageUrl)) + .cancelOnDisappear(true) + .downsampling(size: CGSize(width: 46.7, height: 46.7)) + .resizable() + .frame(width: 46.7, height: 46.7) + .clipShape(Circle()) + + Text(channel.nickname) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + } + .frame(width: screenSize().width - 26.7) + .contentShape(Rectangle()) + .onTapGesture {} + } + } + } else { + Text("검색 결과가 없습니다.") + .font(.custom(Font.medium.rawValue, size: 18.3)) + .foregroundColor(.white) + .padding(.top, 40) + } + } + .padding(.vertical, 40) + .padding(.horizontal, 26.7) + } else { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 26.7) { + ForEach(viewModel.explorerSections, id: \.self) { section in + VStack(alignment: .leading, spacing: 13.3) { + if let coloredTitle = section.coloredTitle, let color = section.color { + let titleArray = section.title.components(separatedBy: coloredTitle) + HStack(spacing: 0) { + Text(titleArray[0]) + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "eeeeee")) + + 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(hex: "eeeeee")) + } + } + .frame(width: screenSize().width - 26.7, alignment: .leading) + } else { + Text(section.title) + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(width: screenSize().width - 26.7, alignment: .leading) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 13.3) { + ForEach(section.creators, id: \.self) { creator in + VStack(spacing: 0) { + KFImage(URL(string: creator.profileImageUrl)) + .cancelOnDisappear(true) + .downsampling(size: CGSize(width: 93.3, height: 93.3)) + .resizable() + .frame(width: 93.3, height: 93.3) + .clipShape(Circle()) + + Text(creator.nickname) + .font(.custom(Font.medium.rawValue, size: 11.3)) + .foregroundColor(Color(hex: "eeeeee")) + .lineLimit(1) + .frame(width: 93.3) + .padding(.top, 13.3) + + Text(creator.tags) + .font(.custom(Font.medium.rawValue, size: 10)) + .foregroundColor(Color(hex: "9970ff")) + .lineLimit(1) + .frame(width: 93.3) + .padding(.top, 3.3) + } + .contentShape(Rectangle()) + .onTapGesture {} + } + } + } + .frame(width: screenSize().width - 26.7, alignment: .leading) + } + } + } + .padding(.vertical, 40) + .padding(.horizontal, 26.7) + } + } + } + } + .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(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + .onAppear { + viewModel.getExplorer() + } + .onTapGesture { + hideKeyboard() + } + } } } diff --git a/SodaLive/Sources/Explorer/ExplorerViewModel.swift b/SodaLive/Sources/Explorer/ExplorerViewModel.swift new file mode 100644 index 0000000..7a4bfd4 --- /dev/null +++ b/SodaLive/Sources/Explorer/ExplorerViewModel.swift @@ -0,0 +1,112 @@ +// +// ExplorerViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Combine + +final class ExplorerViewModel: ObservableObject { + + private let repository = ExplorerRepository() + private var subscription = Set() + + @Published var explorerSections = [GetExplorerSectionResponse]() + @Published var channelResponses = [GetRoomDetailUser]() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var channel = "" + + init() { + _channel = Published(initialValue: "") + $channel + .debounce(for: .seconds(0.3), scheduler: RunLoop.main) + .sink { [unowned self] value in + DEBUG_LOG("value: \(value)") + if value.count > 1 { + searchChannel() + } + } + .store(in: &subscription) + } + + func getExplorer() { + explorerSections.removeAll() + isLoading = true + + repository.getExplorer() + .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.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.explorerSections.append(contentsOf: data.sections) + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + private func searchChannel() { + repository.searchChannel(channel: channel) + .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<[GetRoomDetailUser]>.self, from: responseData) + + if let data = decoded.data, decoded.success { + channelResponses.removeAll() + channelResponses.append(contentsOf: data) + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } +} diff --git a/SodaLive/Sources/Explorer/GetExplorerResponse.swift b/SodaLive/Sources/Explorer/GetExplorerResponse.swift new file mode 100644 index 0000000..9d8ae6c --- /dev/null +++ b/SodaLive/Sources/Explorer/GetExplorerResponse.swift @@ -0,0 +1,27 @@ +// +// GetExplorerResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation + +struct GetExplorerResponse: Decodable { + let sections: [GetExplorerSectionResponse] +} + +struct GetExplorerSectionResponse: Decodable, Hashable { + let title: String + let coloredTitle: String? + let color: String? + let creators: [GetExplorerSectionCreatorResponse] +} + +struct GetExplorerSectionCreatorResponse: Decodable, Hashable { + let id: Int + let nickname: String + let tags: String + let profileImageUrl: String +} + diff --git a/SodaLive/Sources/Live/Room/Detail/GetRoomDetailResponse.swift b/SodaLive/Sources/Live/Room/Detail/GetRoomDetailResponse.swift new file mode 100644 index 0000000..62cb9e1 --- /dev/null +++ b/SodaLive/Sources/Live/Room/Detail/GetRoomDetailResponse.swift @@ -0,0 +1,14 @@ +// +// GetRoomDetailResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation + +struct GetRoomDetailUser: Decodable, Hashable { + let id: Int + let nickname: String + let profileImageUrl: String +}