탐색 메인 페이지
This commit is contained in:
parent
f8bab4c232
commit
943e1d9f7f
|
@ -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
|
||||
}
|
||||
}
|
BIN
SodaLive/Resources/Assets.xcassets/ic_close_white.imageset/ic_close_white.png
vendored
Normal file
BIN
SodaLive/Resources/Assets.xcassets/ic_close_white.imageset/ic_close_white.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 386 B |
21
SodaLive/Resources/Assets.xcassets/ic_title_search_black.imageset/Contents.json
vendored
Normal file
21
SodaLive/Resources/Assets.xcassets/ic_title_search_black.imageset/Contents.json
vendored
Normal file
|
@ -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
|
||||
}
|
||||
}
|
BIN
SodaLive/Resources/Assets.xcassets/ic_title_search_black.imageset/ic_title_search_black.png
vendored
Normal file
BIN
SodaLive/Resources/Assets.xcassets/ic_title_search_black.imageset/ic_title_search_black.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
|
@ -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))"]
|
||||
}
|
||||
}
|
|
@ -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<ExplorerApi>()
|
||||
|
||||
func getExplorer() -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(.getExplorer)
|
||||
}
|
||||
|
||||
func searchChannel(channel: String) -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(.searchChannel(channel: channel))
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
@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<GetExplorerResponse>.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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue