탐색 메인 페이지

This commit is contained in:
Yu Sung 2023-08-10 13:01:16 +09:00
parent f8bab4c232
commit 943e1d9f7f
10 changed files with 434 additions and 1 deletions

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -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))"]
}
}

View File

@ -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))
}
}

View File

@ -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()
}
}
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}