라이브 - 방만들기 추가
This commit is contained in:
		
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_select_check.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_select_check.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_select_check.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_select_check.imageset/ic_select_check.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_select_check.imageset/ic_select_check.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 392 B | 
| @@ -71,4 +71,9 @@ enum AppStep { | ||||
|     case userProfileFanTalkAll(userId: Int) | ||||
|      | ||||
|     case creatorNoticeWrite(notice: String) | ||||
|      | ||||
|     case createLive( | ||||
|         timeSettingMode: LiveRoomCreateViewModel.TimeSettingMode, | ||||
|         onSuccess: (CreateLiveRoomResponse) -> Void | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -104,6 +104,12 @@ struct ContentView: View { | ||||
|             case .contentDetail(let contentId): | ||||
|                 ContentDetailView(contentId: contentId) | ||||
|                  | ||||
|             case .createLive(let timeSettingMode, let onSuccess): | ||||
|                 LiveRoomCreateView( | ||||
|                     timeSettingMode: timeSettingMode, | ||||
|                     onSuccess: onSuccess | ||||
|                 ) | ||||
|                  | ||||
|             default: | ||||
|                 EmptyView() | ||||
|                     .frame(width: 0, height: 0, alignment: .topLeading) | ||||
|   | ||||
| @@ -17,6 +17,10 @@ enum LiveApi { | ||||
|     case getRoomDetail(roomId: Int) | ||||
|     case makeReservation(request: MakeLiveReservationRequest) | ||||
|     case enterRoom(request: EnterOrQuitLiveRoomRequest) | ||||
|     case getTags | ||||
|     case getRecentRoomInfo | ||||
|     case createRoom(parameters: [MultipartFormData]) | ||||
|     case startLive(request: StartLiveRequest) | ||||
| } | ||||
|  | ||||
| extension LiveApi: TargetType { | ||||
| @@ -49,18 +53,30 @@ extension LiveApi: TargetType { | ||||
|              | ||||
|         case .enterRoom: | ||||
|             return "/live/room/enter" | ||||
|              | ||||
|         case .getTags: | ||||
|             return "/live/tag" | ||||
|              | ||||
|         case .getRecentRoomInfo: | ||||
|             return "/live/room/recent-room-info" | ||||
|              | ||||
|         case .createRoom: | ||||
|             return "/live/room" | ||||
|              | ||||
|         case .startLive: | ||||
|             return "/live/room/start" | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     var method: Moya.Method { | ||||
|         switch self { | ||||
|         case .roomList, .recentVisitRoomUsers, .getReservations, .getReservation, .getRoomDetail: | ||||
|         case .roomList, .recentVisitRoomUsers, .getReservations, .getReservation, .getRoomDetail, .getTags, .getRecentRoomInfo: | ||||
|             return .get | ||||
|              | ||||
|         case .makeReservation, .enterRoom: | ||||
|         case .makeReservation, .enterRoom, .createRoom: | ||||
|             return .post | ||||
|              | ||||
|         case .cancelReservation: | ||||
|         case .cancelReservation, .startLive: | ||||
|             return .put | ||||
|         } | ||||
|     } | ||||
| @@ -83,7 +99,7 @@ extension LiveApi: TargetType { | ||||
|                 parameters: parameters, | ||||
|                 encoding: URLEncoding.queryString) | ||||
|              | ||||
|         case .recentVisitRoomUsers: | ||||
|         case .recentVisitRoomUsers, .getTags, .getRecentRoomInfo: | ||||
|             return .requestPlain | ||||
|              | ||||
|         case .getReservations(let isActive): | ||||
| @@ -117,6 +133,12 @@ extension LiveApi: TargetType { | ||||
|              | ||||
|         case .enterRoom(let request): | ||||
|             return .requestJSONEncodable(request) | ||||
|              | ||||
|         case .createRoom(let parameters): | ||||
|             return .uploadMultipart(parameters) | ||||
|              | ||||
|         case .startLive(let request): | ||||
|             return .requestJSONEncodable(request) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|   | ||||
| @@ -44,4 +44,16 @@ final class LiveRepository { | ||||
|     func enterRoom(request: EnterOrQuitLiveRoomRequest) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.enterRoom(request: request)) | ||||
|     } | ||||
|      | ||||
|     func getRecentRoomInfo() -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getRecentRoomInfo) | ||||
|     } | ||||
|      | ||||
|     func createRoom(parameters: [MultipartFormData]) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.createRoom(parameters: parameters)) | ||||
|     } | ||||
|      | ||||
|     func startLive(roomId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.startLive(request: StartLiveRequest(roomId: roomId))) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -59,7 +59,9 @@ struct LiveView: View { | ||||
|                                 SectionLiveNowView( | ||||
|                                     items: viewModel.liveNowItems, | ||||
|                                     onClickParticipant: {_ in}, | ||||
|                                     onTapCreateLive: {} | ||||
|                                     onTapCreateLive: { | ||||
|                                         AppState.shared.setAppStep(step: .createLive(timeSettingMode: .NOW, onSuccess: onCreateSuccess)) | ||||
|                                     } | ||||
|                                 ) | ||||
|                                 .padding(.top, 40) | ||||
|                             } | ||||
| @@ -80,7 +82,9 @@ struct LiveView: View { | ||||
|                                     onClickCancel: { viewModel.getSummary() }, | ||||
|                                     onClickStart: {_ in}, | ||||
|                                     onClickReservation: {_ in}, | ||||
|                                     onTapCreateLive: {} | ||||
|                                     onTapCreateLive: { | ||||
|                                         AppState.shared.setAppStep(step: .createLive(timeSettingMode: .RESERVATION, onSuccess: onCreateSuccess)) | ||||
|                                     } | ||||
|                                 ) | ||||
|                             } | ||||
|                         } | ||||
| @@ -90,10 +94,14 @@ struct LiveView: View { | ||||
|                         viewModel.getSummary() | ||||
|                     } | ||||
|                      | ||||
|                     Image("btn_make_live") | ||||
|                         .padding(.trailing, 16) | ||||
|                         .padding(.bottom, 16) | ||||
|                         .onTapGesture {} | ||||
|                     if !appState.isShowPlayer { | ||||
|                         Image("btn_make_live") | ||||
|                             .padding(.trailing, 16) | ||||
|                             .padding(.bottom, 16) | ||||
|                             .onTapGesture { | ||||
|                                 AppState.shared.setAppStep(step: .createLive(timeSettingMode: .NOW, onSuccess: onCreateSuccess)) | ||||
|                             } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|              | ||||
| @@ -123,6 +131,36 @@ struct LiveView: View { | ||||
|                 LoadingView() | ||||
|             } | ||||
|         } | ||||
|         .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { | ||||
|             GeometryReader { geo in | ||||
|                 HStack { | ||||
|                     Spacer() | ||||
|                     Text(viewModel.errorMessage) | ||||
|                         .padding(.vertical, 13.3) | ||||
|                         .padding(.horizontal, 6.7) | ||||
|                         .frame(width: geo.size.width - 66.7, alignment: .center) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                         .background(Color(hex: "9970ff")) | ||||
|                         .foregroundColor(Color.white) | ||||
|                         .multilineTextAlignment(.leading) | ||||
|                         .fixedSize(horizontal: false, vertical: true) | ||||
|                         .cornerRadius(20) | ||||
|                         .padding(.top, 66.7) | ||||
|                     Spacer() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func onCreateSuccess(response: CreateLiveRoomResponse) { | ||||
|         viewModel.getSummary() | ||||
|         if let _ = response.channelName { | ||||
|             viewModel.enterRoom(roomId: response.id!) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func processStart(roomId: Int) { | ||||
|         viewModel.startLive(roomId: roomId) | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -176,6 +176,90 @@ final class LiveViewModel: ObservableObject { | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
|      | ||||
|     func enterRoom(roomId: Int, onSuccess: (() -> Void)? = nil, password: Int? = nil) { | ||||
|         isLoading = true | ||||
|         let request = EnterOrQuitLiveRoomRequest(roomId: roomId, password: password) | ||||
|         repository.enterRoom(request: request) | ||||
|             .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(ApiResponseWithoutData.self, from: responseData) | ||||
|                      | ||||
|                     if decoded.success { | ||||
|                         AppState.shared.roomId = roomId | ||||
|                          | ||||
|                         if let onSuccess = onSuccess { | ||||
|                             onSuccess() | ||||
|                         } else { | ||||
|                             if roomId > 0 { | ||||
|                                 AppState.shared.isShowPlayer = true | ||||
|                             } | ||||
|                         } | ||||
|                     } 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) | ||||
|     } | ||||
|      | ||||
|     func startLive(roomId: Int) { | ||||
|         isLoading = true | ||||
|         repository.startLive(roomId: roomId) | ||||
|             .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(ApiResponseWithoutData.self, from: responseData) | ||||
|                      | ||||
|                     if decoded.success { | ||||
|                         getSummary() | ||||
|                         enterRoom(roomId: roomId) | ||||
|                     } 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 getFollowedChannelList() { | ||||
|         followedChannelItems.removeAll() | ||||
|         isFollowedChannelLoading = true | ||||
|   | ||||
| @@ -0,0 +1,22 @@ | ||||
| // | ||||
| //  CreateLiveRoomRequest.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct CreateLiveRoomRequest: Encodable { | ||||
|     let title: String | ||||
|     let content: String | ||||
|     let coverImageUrl: String? | ||||
|     let tags: [String] | ||||
|     let numberOfPeople: Int | ||||
|     var isAdult: Bool = false | ||||
|     var price = 0 | ||||
|     var type: LiveRoomCreateViewModel.LiveRoomType = .OPEN | ||||
|     var password: String? = nil | ||||
|     let timezone: String = TimeZone.current.identifier | ||||
|     var beginDateTimeString: String? = nil | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| // | ||||
| //  CreateLiveRoomResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct CreateLiveRoomResponse: Decodable { | ||||
|     let id: Int? | ||||
|     let channelName: String? | ||||
| } | ||||
| @@ -0,0 +1,16 @@ | ||||
| // | ||||
| //  GetRecentRoomInfoResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct GetRecentRoomInfoResponse: Decodable { | ||||
|     let title: String | ||||
|     let notice: String | ||||
|     let coverImageUrl: String | ||||
|     let coverImagePath: String | ||||
|     let numberOfPeople: Int | ||||
| } | ||||
							
								
								
									
										787
									
								
								SodaLive/Sources/Live/Room/Create/LiveRoomCreateView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										787
									
								
								SodaLive/Sources/Live/Room/Create/LiveRoomCreateView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,787 @@ | ||||
| // | ||||
| //  LiveRoomCreateView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct LiveRoomCreateView: View { | ||||
|      | ||||
|     @StateObject var keyboardHandler = KeyboardHandler() | ||||
|     @StateObject var viewModel = LiveRoomCreateViewModel() | ||||
|      | ||||
|     @State private var isShowPhotoPicker = false | ||||
|     @State private var isShowSelectTagView = false | ||||
|     @State private var isShowSelectDateView = false | ||||
|     @State private var isShowSelectTimeView = false | ||||
|      | ||||
|     let timeSettingMode: LiveRoomCreateViewModel.TimeSettingMode | ||||
|     let onSuccess: (CreateLiveRoomResponse) -> Void | ||||
|      | ||||
|     init(timeSettingMode: LiveRoomCreateViewModel.TimeSettingMode, onSuccess: @escaping (CreateLiveRoomResponse) -> Void) { | ||||
|         UITextView.appearance().backgroundColor = .clear | ||||
|         UIScrollView.appearance().bounces = false | ||||
|         self.onSuccess = onSuccess | ||||
|         self.timeSettingMode = timeSettingMode | ||||
|     } | ||||
|      | ||||
|     var body: some View { | ||||
|         BaseView(isLoading: $viewModel.isLoading) { | ||||
|             GeometryReader { proxy in | ||||
|                 ZStack { | ||||
|                     VStack(spacing: 0) { | ||||
|                         HStack(spacing: 0) { | ||||
|                             Button { AppState.shared.back() } label: { | ||||
|                                 Image("ic_back") | ||||
|                                     .resizable() | ||||
|                                     .frame(width: 20, height: 20) | ||||
|                                  | ||||
|                                 Text("라이브 만들기") | ||||
|                                     .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                             } | ||||
|                              | ||||
|                             Spacer() | ||||
|                              | ||||
|                             if viewModel.isShowGetRecentInfoButton { | ||||
|                                 Text("최근 데이터 가져오기") | ||||
|                                     .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                                     .foregroundColor(Color(hex: "9970ff")) | ||||
|                                     .padding(.vertical, 8) | ||||
|                                     .padding(.horizontal, 10.7) | ||||
|                                     .overlay( | ||||
|                                         RoundedRectangle(cornerRadius: 8) | ||||
|                                             .stroke() | ||||
|                                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                                     ) | ||||
|                                     .onTapGesture { | ||||
|                                         viewModel.getRecentInfo() | ||||
|                                     } | ||||
|                             } | ||||
|                         } | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                         .frame(height: 50) | ||||
|                         .background(Color.black) | ||||
|                          | ||||
|                         ScrollView(.vertical, showsIndicators: false) { | ||||
|                             VStack(spacing: 0) { | ||||
|                                 VStack(spacing: 0) { | ||||
|                                     Text("썸네일") | ||||
|                                         .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                                         .padding(.horizontal, 13.3) | ||||
|                                         .padding(.top, 13.3) | ||||
|                                         .frame(width: screenSize().width, alignment: .leading) | ||||
|                                      | ||||
|                                     ZStack { | ||||
|                                         if let selectedImage = viewModel.coverImage { | ||||
|                                             Image(uiImage: selectedImage) | ||||
|                                                 .resizable() | ||||
|                                                 .scaledToFill() | ||||
|                                                 .frame(width: 80, height: 116.8, alignment: .top) | ||||
|                                                 .cornerRadius(10) | ||||
|                                         } else if let coverImageUrl = viewModel.coverImageUrl { | ||||
|                                             KFImage(URL(string: coverImageUrl)) | ||||
|                                                 .resizable() | ||||
|                                                 .scaledToFill() | ||||
|                                                 .frame(width: 80, height: 116.8, alignment: .top) | ||||
|                                                 .clipped() | ||||
|                                                 .cornerRadius(10) | ||||
|                                         } else { | ||||
|                                             Image("ic_logo") | ||||
|                                                 .resizable() | ||||
|                                                 .scaledToFit() | ||||
|                                                 .frame(width: 80, height: 116.8) | ||||
|                                                 .background(Color(hex: "3e3358")) | ||||
|                                                 .cornerRadius(10) | ||||
|                                         } | ||||
|                                          | ||||
|                                         Image("ic_camera") | ||||
|                                             .padding(10) | ||||
|                                             .background(Color(hex: "9970ff")) | ||||
|                                             .cornerRadius(30) | ||||
|                                             .offset(x: 40, y: 40) | ||||
|                                     } | ||||
|                                     .frame(alignment: .bottomTrailing) | ||||
|                                     .padding(.top, 13.3) | ||||
|                                     .onTapGesture { | ||||
|                                         isShowPhotoPicker = true | ||||
|                                     } | ||||
|                                      | ||||
|                                     TitleInputView() | ||||
|                                         .frame(width: screenSize().width - 26.7) | ||||
|                                         .padding(.top, 33.3) | ||||
|                                      | ||||
|                                     TagSelectView() | ||||
|                                         .frame(width: screenSize().width - 26.7) | ||||
|                                         .padding(.top, 33.3) | ||||
|                                 } | ||||
|                                  | ||||
|                                 VStack(spacing: 0) { | ||||
|                                     ContentInputView() | ||||
|                                         .frame(width: screenSize().width - 26.7) | ||||
|                                         .padding(.top, 33.3) | ||||
|                                      | ||||
|                                     if viewModel.roomType != .SECRET { | ||||
|                                         TimeSettingView() | ||||
|                                             .padding(.top, 33.3) | ||||
|                                     } | ||||
|                                      | ||||
|                                     NumberOfPeopleLimitView() | ||||
|                                         .frame(width: screenSize().width - 26.7) | ||||
|                                         .padding(.top, 33.3) | ||||
|                                 } | ||||
|                                  | ||||
|                                 VStack(spacing: 0) { | ||||
|                                     RoomTypeSettingView() | ||||
|                                         .frame(width: screenSize().width - 26.7) | ||||
|                                         .padding(.top, 33.3) | ||||
|                                      | ||||
|                                     if UserDefaults.bool(forKey: .auth) { | ||||
|                                         AdultSettingView() | ||||
|                                             .frame(width: screenSize().width - 26.7) | ||||
|                                             .padding(.top, 33.3) | ||||
|                                     } | ||||
|                                      | ||||
|                                     if UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue { | ||||
|                                         PriceSettingView() | ||||
|                                             .frame(width: screenSize().width - 26.7) | ||||
|                                             .padding(.top, 33.3) | ||||
|                                     } | ||||
|                                 } | ||||
|                                  | ||||
|                                 HStack(alignment: .top, spacing: 0) { | ||||
|                                     Button(action: { | ||||
|                                         viewModel.createRoom { response in | ||||
|                                             AppState.shared.back() | ||||
|                                              | ||||
|                                             DispatchQueue.main.async { | ||||
|                                                 onSuccess(response) | ||||
|                                             } | ||||
|                                         } | ||||
|                                     }) { | ||||
|                                         Text("라이브 오픈하기") | ||||
|                                             .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                                             .foregroundColor(Color.white) | ||||
|                                             .frame(width: screenSize().width - 26.7, height: 50) | ||||
|                                             .background(Color(hex: "9970ff")) | ||||
|                                             .cornerRadius(10) | ||||
|                                             .padding(.vertical, 13.3) | ||||
|                                     } | ||||
|                                 } | ||||
|                                 .frame(width: screenSize().width) | ||||
|                                 .background(Color(hex: "222222")) | ||||
|                                 .cornerRadius(16.7, corners: [.topLeft, .topRight]) | ||||
|                                 .padding(.top, 30) | ||||
|                                  | ||||
|                                 Rectangle() | ||||
|                                     .foregroundColor(Color(hex: "222222")) | ||||
|                                     .frame(width: screenSize().width, height: keyboardHandler.keyboardHeight) | ||||
|                                  | ||||
|                                 if proxy.safeAreaInsets.bottom > 0 { | ||||
|                                     Rectangle() | ||||
|                                         .foregroundColor(Color(hex: "222222")) | ||||
|                                         .frame(width: screenSize().width, height: 15.3) | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|                     if isShowSelectDateView { | ||||
|                         SelectDateView() | ||||
|                     } | ||||
|                      | ||||
|                     if isShowSelectTimeView { | ||||
|                         SelectTimeView() | ||||
|                     } | ||||
|                      | ||||
|                     if isShowPhotoPicker { | ||||
|                         ImagePicker( | ||||
|                             isShowing: $isShowPhotoPicker, | ||||
|                             selectedImage: $viewModel.coverImage, | ||||
|                             sourceType: .photoLibrary | ||||
|                         ) | ||||
|                     } | ||||
|                      | ||||
|                     GeometryReader { proxy in | ||||
|                         VStack { | ||||
|                             Spacer() | ||||
|                             LiveRoomCreateTagView( | ||||
|                                 isShowing: $isShowSelectTagView, | ||||
|                                 selectedTags: $viewModel.tags | ||||
|                             ) | ||||
|                             .frame(width: proxy.size.width, height: proxy.size.height * 0.9) | ||||
|                             .offset(y: isShowSelectTagView ? 0 : proxy.size.height * 0.9) | ||||
|                             .animation(.easeInOut(duration: 0.49), value: self.isShowSelectTagView) | ||||
|                         } | ||||
|                     } | ||||
|                     .edgesIgnoringSafeArea(.bottom) | ||||
|                 } | ||||
|                 .onTapGesture { | ||||
|                     hideKeyboard() | ||||
|                 } | ||||
|                 .edgesIgnoringSafeArea(.bottom) | ||||
|             } | ||||
|         } | ||||
|         .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.timeSettingMode = timeSettingMode | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func TitleInputView() -> some View { | ||||
|         VStack(spacing: 0) { | ||||
|             Text("제목") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .padding(.horizontal, 13.3) | ||||
|                 .frame(width: screenSize().width, alignment: .leading) | ||||
|              | ||||
|             TextField("라이브 제목을 입력하세요", text: $viewModel.title) | ||||
|                 .autocapitalization(.none) | ||||
|                 .disableAutocorrection(true) | ||||
|                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .accentColor(Color(hex: "9970ff")) | ||||
|                 .keyboardType(.default) | ||||
|                 .padding(.top, 12) | ||||
|                 .padding(.horizontal, 6.7) | ||||
|              | ||||
|             Rectangle() | ||||
|                 .frame(height: 1) | ||||
|                 .foregroundColor(Color(hex: "909090").opacity(0.7)) | ||||
|                 .padding(.top, 8.3) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func TagSelectView() -> some View { | ||||
|         VStack(alignment: .leading, spacing: 13.3) { | ||||
|             Text("관심사") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|              | ||||
|             Button(action: { | ||||
|                 hideKeyboard() | ||||
|                 isShowSelectTagView = true | ||||
|             }) { | ||||
|                 Text("관심사 선택") | ||||
|                     .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                     .foregroundColor(Color(hex: "9970ff")) | ||||
|                     .padding(.vertical, 13.7) | ||||
|                     .frame(width: screenSize().width - 26.7) | ||||
|                     .background(Color(hex: "9970ff").opacity(0.2)) | ||||
|                     .cornerRadius(24.3) | ||||
|                     .overlay( | ||||
|                         RoundedRectangle(cornerRadius: 24.3) | ||||
|                             .stroke() | ||||
|                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                     ) | ||||
|             } | ||||
|              | ||||
|             ScrollView(.horizontal, showsIndicators: false) { | ||||
|                 HStack(spacing: 10) { | ||||
|                     ForEach(viewModel.tags, id: \.self) { tag in | ||||
|                         HStack(spacing: 6.7) { | ||||
|                             Text(tag) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                                 .foregroundColor(.white) | ||||
|                              | ||||
|                             Image("ic_circle_x") | ||||
|                                 .onTapGesture { | ||||
|                                     if let index = viewModel.tags.firstIndex(of: tag) { | ||||
|                                         viewModel.tags.remove(at: index) | ||||
|                                     } | ||||
|                                 } | ||||
|                         } | ||||
|                         .padding(10) | ||||
|                         .background(Color(hex: "9970ff")) | ||||
|                         .cornerRadius(24.3) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .padding(.top, 13.3) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func ContentInputView() -> some View { | ||||
|         VStack(spacing: 13.3) { | ||||
|             HStack(spacing: 0) { | ||||
|                 Text("공지") | ||||
|                     .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                  | ||||
|                 Spacer() | ||||
|                  | ||||
|                 Text("\(viewModel.content.count)자") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color(hex: "ff5c49")) + | ||||
|                 Text(" / 1000자") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color(hex: "777777")) | ||||
|             } | ||||
|              | ||||
|             TextViewWrapper( | ||||
|                 text: $viewModel.content, | ||||
|                 placeholder: viewModel.placeholder, | ||||
|                 textColorHex: "eeeeee", | ||||
|                 backgroundColorHex: "222222" | ||||
|             ) | ||||
|             .frame(width: screenSize().width - 26.7, height: 133.3) | ||||
|             .cornerRadius(6.7) | ||||
|             .padding(.top, 13.3) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func TimeSettingView() -> some View { | ||||
|         VStack(spacing: 0) { | ||||
|             Text("시간설정") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .frame(width: screenSize().width - 26.7, alignment: .leading) | ||||
|              | ||||
|             HStack(spacing: 13.3) { | ||||
|                 TimeSettingSelectButton( | ||||
|                     title: "지금 즉시", | ||||
|                     timeSettingMode: .NOW, | ||||
|                     buttonWidth: (screenSize().width - 40) / 2 | ||||
|                 ) | ||||
|                  | ||||
|                 TimeSettingSelectButton( | ||||
|                     title: "예약 설정", | ||||
|                     timeSettingMode: .RESERVATION, | ||||
|                     buttonWidth: (screenSize().width - 40) / 2 | ||||
|                 ) | ||||
|             } | ||||
|             .padding(.top, 13.3) | ||||
|              | ||||
|             if viewModel.timeSettingMode == .RESERVATION { | ||||
|                 ReservationDateTimeView(buttonWidth: (screenSize().width - 40) / 2) | ||||
|                     .padding(.top, 22.7) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func TimeSettingSelectButton( | ||||
|         title: String, | ||||
|         timeSettingMode: LiveRoomCreateViewModel.TimeSettingMode, | ||||
|         buttonWidth: CGFloat | ||||
|     ) -> some View { | ||||
|         HStack(spacing: 6.7) { | ||||
|             if viewModel.timeSettingMode == timeSettingMode { | ||||
|                 Image("ic_select_check") | ||||
|             } | ||||
|              | ||||
|             Text(title) | ||||
|                 .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                 .foregroundColor( | ||||
|                     viewModel.timeSettingMode == timeSettingMode ? | ||||
|                         .white : | ||||
|                         Color(hex: "9970ff") | ||||
|                 ) | ||||
|         } | ||||
|         .frame(width: buttonWidth, height: 48.7) | ||||
|         .background( | ||||
|             viewModel.timeSettingMode == timeSettingMode ? | ||||
|             Color(hex: "9970ff") : | ||||
|                 Color(hex: "1f1734") | ||||
|         ) | ||||
|         .cornerRadius(6.7) | ||||
|         .onTapGesture { | ||||
|             hideKeyboard() | ||||
|             if viewModel.timeSettingMode != timeSettingMode { | ||||
|                 viewModel.timeSettingMode = timeSettingMode | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func ReservationDateTimeView(buttonWidth: CGFloat) -> some View { | ||||
|         HStack(spacing: 13.3) { | ||||
|             VStack(alignment: .leading, spacing: 6.7) { | ||||
|                 Text("예약 날짜") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                  | ||||
|                 Button(action: { | ||||
|                     hideKeyboard() | ||||
|                     self.isShowSelectDateView = true | ||||
|                 }) { | ||||
|                     Text(viewModel.reservationDateString) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                         .frame(width: buttonWidth, height: 48.7) | ||||
|                         .overlay( | ||||
|                             RoundedRectangle(cornerRadius: 6.7) | ||||
|                                 .stroke(Color(hex: "9970ff"), lineWidth: 1.3) | ||||
|                         ) | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             VStack(alignment: .leading, spacing: 6.7) { | ||||
|                 Text("예약 시간") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                  | ||||
|                 Button(action: { | ||||
|                     hideKeyboard() | ||||
|                     self.isShowSelectTimeView = true | ||||
|                 }) { | ||||
|                     Text(viewModel.reservationTimeString) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                         .frame(width: buttonWidth, height: 48.7) | ||||
|                         .overlay( | ||||
|                             RoundedRectangle(cornerRadius: 6.7) | ||||
|                                 .stroke(Color(hex: "9970ff"), lineWidth: 1.3) | ||||
|                         ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         .frame(width: screenSize().width) | ||||
|         .padding(.vertical, 13.3) | ||||
|         .background(Color(hex: "222222")) | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func NumberOfPeopleLimitView() -> some View { | ||||
|         VStack(spacing: 13.3) { | ||||
|             Text("참여인원 설정") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .frame(width: screenSize().width - 26.7, alignment: .leading) | ||||
|              | ||||
|             TextField("최대 인원 999명", text: $viewModel.numberOfPeople) | ||||
|                 .autocapitalization(.none) | ||||
|                 .disableAutocorrection(true) | ||||
|                 .multilineTextAlignment(.center) | ||||
|                 .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .accentColor(Color(hex: "9970ff")) | ||||
|                 .keyboardType(.numberPad) | ||||
|                 .padding(.vertical, 15.7) | ||||
|                 .frame(width: screenSize().width - 26.7, alignment: .center) | ||||
|                 .background(Color(hex: "222222")) | ||||
|                 .cornerRadius(6.7) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func SelectDateView() -> some View { | ||||
|         GeometryReader { proxy in | ||||
|             ZStack { | ||||
|                 Color | ||||
|                     .black | ||||
|                     .opacity(0.5) | ||||
|                     .edgesIgnoringSafeArea(.all) | ||||
|                  | ||||
|                 VStack(spacing: 0) { | ||||
|                     DatePicker("", selection: $viewModel.reservationDate, in: Date()..., displayedComponents: .date) | ||||
|                         .datePickerStyle(WheelDatePickerStyle()) | ||||
|                         .labelsHidden() | ||||
|                         .environment(\.locale, Locale.init(identifier: "ko")) | ||||
|                         .frame(width: proxy.size.width) | ||||
|                      | ||||
|                     Button(action: { self.isShowSelectDateView = false }) { | ||||
|                         Text("확인") | ||||
|                             .font(.system(size: 16)) | ||||
|                             .foregroundColor(Color(hex: "eeeeee")) | ||||
|                             .padding(.vertical, 10) | ||||
|                             .frame(width: proxy.size.width - 53.4) | ||||
|                     } | ||||
|                 } | ||||
|                 .background(Color(hex: "222222")) | ||||
|                 .cornerRadius(6.7) | ||||
|             } | ||||
|             .frame(width: proxy.size.width) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func SelectTimeView() -> some View { | ||||
|         GeometryReader { proxy in | ||||
|             ZStack { | ||||
|                 Color | ||||
|                     .black | ||||
|                     .opacity(0.5) | ||||
|                     .edgesIgnoringSafeArea(.all) | ||||
|                  | ||||
|                 VStack(spacing: 0) { | ||||
|                     DatePicker("", selection: $viewModel.reservationTime, displayedComponents: .hourAndMinute) | ||||
|                         .datePickerStyle(WheelDatePickerStyle()) | ||||
|                         .labelsHidden() | ||||
|                         .environment(\.locale, Locale.init(identifier: "ko")) | ||||
|                         .frame(width: proxy.size.width - 53.4) | ||||
|                      | ||||
|                     Button(action: { self.isShowSelectTimeView = false }) { | ||||
|                         Text("확인") | ||||
|                             .font(.system(size: 16)) | ||||
|                             .foregroundColor(Color(hex: "eeeeee")) | ||||
|                             .padding(.vertical, 10) | ||||
|                             .frame(width: proxy.size.width) | ||||
|                     } | ||||
|                 } | ||||
|                 .background(Color(hex: "222222")) | ||||
|                 .cornerRadius(6.7) | ||||
|             } | ||||
|             .frame(width: proxy.size.width) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func RoomTypeSettingView() -> some View { | ||||
|         VStack(spacing: 0) { | ||||
|             Text("공개 설정") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .frame(width: screenSize().width - 26.7, alignment: .leading) | ||||
|              | ||||
|             HStack(spacing: 13.3) { | ||||
|                 RoomTypeSelectButton( | ||||
|                     title: "공개", | ||||
|                     type: .OPEN, | ||||
|                     buttonWidth: (screenSize().width - 40) / 2 | ||||
|                 ) | ||||
|                  | ||||
|                 RoomTypeSelectButton( | ||||
|                     title: "비공개", | ||||
|                     type: .PRIVATE, | ||||
|                     buttonWidth: (screenSize().width - 40) / 2 | ||||
|                 ) | ||||
|             } | ||||
|             .padding(.top, 13.3) | ||||
|              | ||||
|             if viewModel.roomType == .PRIVATE { | ||||
|                 RoomPasswordView() | ||||
|                     .padding(.top, 33.3) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func RoomTypeSelectButton(title: String, type: LiveRoomCreateViewModel.LiveRoomType, buttonWidth: CGFloat) -> some View { | ||||
|          | ||||
|         HStack(spacing: 6.7) { | ||||
|             if viewModel.roomType == type { | ||||
|                 Image("ic_select_check") | ||||
|             } | ||||
|              | ||||
|             Text(title) | ||||
|                 .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                 .foregroundColor( | ||||
|                     viewModel.roomType == type ? | ||||
|                         .white : | ||||
|                         Color(hex: "9970ff") | ||||
|                 ) | ||||
|         } | ||||
|         .frame(width: buttonWidth, height: 48.7) | ||||
|         .background( | ||||
|             viewModel.roomType == type ? | ||||
|             Color(hex: "9970ff") : | ||||
|                 Color(hex: "1f1734") | ||||
|         ) | ||||
|         .cornerRadius(6.7) | ||||
|         .onTapGesture { | ||||
|             hideKeyboard() | ||||
|             if viewModel.roomType != type { | ||||
|                 viewModel.roomType = type | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func RoomPasswordView() -> some View { | ||||
|         VStack(spacing: 13.3) { | ||||
|             Text("방 비밀번호 입력") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .frame(width: screenSize().width - 26.7, alignment: .leading) | ||||
|              | ||||
|             TextField("방 입장 비밀번호 6자리를 입력해 주세요.", text: $viewModel.password) | ||||
|                 .autocapitalization(.none) | ||||
|                 .disableAutocorrection(true) | ||||
|                 .multilineTextAlignment(.center) | ||||
|                 .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .accentColor(Color(hex: "9970ff")) | ||||
|                 .keyboardType(.numberPad) | ||||
|                 .padding(.vertical, 15.7) | ||||
|                 .frame(width: screenSize().width - 26.7, alignment: .center) | ||||
|                 .background(Color(hex: "222222")) | ||||
|                 .cornerRadius(6.7) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func AdultSettingView() -> some View { | ||||
|         VStack(spacing: 13.3) { | ||||
|             Text("연령 제한") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .frame(width: screenSize().width - 26.7, alignment: .leading) | ||||
|              | ||||
|             HStack(spacing: 13.3) { | ||||
|                 AdultSettingSelectButton( | ||||
|                     title: "전체 연령", | ||||
|                     isAdult: false, | ||||
|                     buttonWidth: (screenSize().width - 40) / 2 | ||||
|                 ) | ||||
|                  | ||||
|                 AdultSettingSelectButton( | ||||
|                     title: "19세 이상", | ||||
|                     isAdult: true, | ||||
|                     buttonWidth: (screenSize().width - 40) / 2 | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func AdultSettingSelectButton(title: String, isAdult: Bool, buttonWidth: CGFloat) -> some View { | ||||
|         HStack(spacing: 6.7) { | ||||
|             if viewModel.isAdult == isAdult { | ||||
|                 Image("ic_select_check") | ||||
|             } | ||||
|              | ||||
|             Text(title) | ||||
|                 .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                 .foregroundColor( | ||||
|                     viewModel.isAdult == isAdult ? | ||||
|                         .white : | ||||
|                         Color(hex: "9970ff") | ||||
|                 ) | ||||
|         } | ||||
|         .frame(width: buttonWidth, height: 48.7) | ||||
|         .background( | ||||
|             viewModel.isAdult == isAdult ? | ||||
|             Color(hex: "9970ff") : | ||||
|                 Color(hex: "1f1734") | ||||
|         ) | ||||
|         .cornerRadius(6.7) | ||||
|         .onTapGesture { | ||||
|             hideKeyboard() | ||||
|             if viewModel.isAdult != isAdult { | ||||
|                 viewModel.isAdult = isAdult | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func PriceSettingView() -> some View { | ||||
|         VStack(spacing: 13.3) { | ||||
|             Text("티켓 가격") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .frame(width: screenSize().width - 26.7, alignment: .leading) | ||||
|              | ||||
|             HStack(spacing: 13.3) { | ||||
|                 PriceButtonView(price: 0, buttonWidth: (screenSize().width - 53) / 3) | ||||
|                  | ||||
|                 PriceButtonView(price: 100, buttonWidth: (screenSize().width - 53) / 3) | ||||
|                  | ||||
|                 PriceButtonView(price: 300, buttonWidth: (screenSize().width - 53) / 3) | ||||
|             } | ||||
|              | ||||
|             HStack(spacing: 13.3) { | ||||
|                 PriceButtonView(price: 500, buttonWidth: (screenSize().width - 53) / 3) | ||||
|                  | ||||
|                 PriceButtonView(price: 1000, buttonWidth: (screenSize().width - 53) / 3) | ||||
|                  | ||||
|                 PriceButtonView(price: 2000, buttonWidth: (screenSize().width - 53) / 3) | ||||
|             } | ||||
|              | ||||
|             HStack(spacing: 0) { | ||||
|                 TextField("", text: $viewModel.priceString) | ||||
|                     .autocapitalization(.none) | ||||
|                     .disableAutocorrection(true) | ||||
|                     .multilineTextAlignment(.center) | ||||
|                     .font(.custom(Font.bold.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color(hex: "9970ff")) | ||||
|                     .accentColor(Color(hex: "9970ff")) | ||||
|                     .keyboardType(.numberPad) | ||||
|                  | ||||
|                 Spacer() | ||||
|                  | ||||
|                 Text("코인") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                     .foregroundColor( | ||||
|                         Color(hex: "9970ff") | ||||
|                     ) | ||||
|             } | ||||
|             .padding(.horizontal, 13.3) | ||||
|             .frame(width: screenSize().width - 26.7, height: 48.7) | ||||
|             .overlay( | ||||
|                 RoundedRectangle(cornerRadius: 6.7) | ||||
|                     .stroke( | ||||
|                         Color(hex: !viewModel.prices.contains(viewModel.price) ? | ||||
|                               "9970ff" : | ||||
|                                 "777777" | ||||
|                              ), | ||||
|                         lineWidth: 1 | ||||
|                     ) | ||||
|             ) | ||||
|             .background( | ||||
|                 !viewModel.prices.contains(viewModel.price) ? | ||||
|                 Color(hex:"9970ff").opacity(0.3): | ||||
|                     Color(hex: "232323") | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func PriceButtonView(price: Int, buttonWidth: CGFloat) -> some View { | ||||
|         HStack(spacing: 6.7) { | ||||
|             Text(price == 0 ? "무료" : "\(price) 코인") | ||||
|                 .font(.custom( | ||||
|                     viewModel.price == price ? | ||||
|                     Font.bold.rawValue : | ||||
|                         Font.medium.rawValue, | ||||
|                     size: 14.7 | ||||
|                 )) | ||||
|                 .foregroundColor( | ||||
|                     Color(hex: viewModel.price == price ? "9970ff" : "777777") | ||||
|                 ) | ||||
|         } | ||||
|         .frame(width: buttonWidth, height: 48.7) | ||||
|         .overlay( | ||||
|             RoundedRectangle(cornerRadius: 6.7) | ||||
|                 .stroke( | ||||
|                     Color(hex: viewModel.price == price ? "9970ff" : "777777"), | ||||
|                     lineWidth: 1 | ||||
|                 ) | ||||
|         ) | ||||
|         .background( | ||||
|             viewModel.price == price ? | ||||
|             Color(hex:"9970ff").opacity(0.3): | ||||
|                 Color(hex: "232323") | ||||
|         ) | ||||
|         .cornerRadius(6.7) | ||||
|         .onTapGesture { | ||||
|             hideKeyboard() | ||||
|             viewModel.priceString = "\(price)" | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										270
									
								
								SodaLive/Sources/Live/Room/Create/LiveRoomCreateViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										270
									
								
								SodaLive/Sources/Live/Room/Create/LiveRoomCreateViewModel.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,270 @@ | ||||
| // | ||||
| //  LiveRoomCreateViewModel.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import UIKit | ||||
| import Moya | ||||
| import Combine | ||||
|  | ||||
| final class LiveRoomCreateViewModel: ObservableObject { | ||||
|     enum TimeSettingMode: String { | ||||
|         case NOW, RESERVATION | ||||
|     } | ||||
|      | ||||
|     enum LiveRoomType: String, Codable { | ||||
|         case OPEN, PRIVATE, SECRET | ||||
|     } | ||||
|      | ||||
|     let prices = [0, 100, 300, 500, 1000, 2000] | ||||
|      | ||||
|     @Published var isLoading = false | ||||
|     @Published var tags: [String] = [] | ||||
|     @Published var title: String = "" | ||||
|     @Published var content: String = "" { | ||||
|         didSet { | ||||
|             if content.count > 1000 { | ||||
|                 content = String(content.prefix(1000)) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @Published var coverImage: UIImage? = nil | ||||
|     @Published var coverImageUrl: String? = nil | ||||
|     @Published var numberOfPeople = "" | ||||
|     @Published var timeSettingMode: TimeSettingMode = .NOW | ||||
|     @Published var reservationDateString: String = Date().convertDateFormat(dateFormat: "yyyy.MM.dd") | ||||
|     @Published var reservationTimeString: String = Date().convertDateFormat(dateFormat: "a hh : mm") | ||||
|      | ||||
|     @Published var roomType: LiveRoomType = .OPEN { | ||||
|         didSet { | ||||
|             if roomType == .SECRET { | ||||
|                 timeSettingMode = .NOW | ||||
|             } | ||||
|              | ||||
|             if roomType != .PRIVATE { | ||||
|                 password = "" | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     @Published var password = "" { | ||||
|         didSet { | ||||
|             if password.count > 6 { | ||||
|                 password = String(password.prefix(6)) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @Published var isAdult = false | ||||
|     @Published var priceString = "0" { | ||||
|         didSet { | ||||
|             if priceString.count > 5 { | ||||
|                 priceString = String(priceString.prefix(5)) | ||||
|             } else { | ||||
|                 if let price = Int(priceString) { | ||||
|                     self.price = price | ||||
|                 } else { | ||||
|                     self.price = 0 | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     @Published var price = 0 | ||||
|      | ||||
|     @Published var errorMessage = "" | ||||
|     @Published var isShowPopup = false | ||||
|     @Published var isShowGetRecentInfoButton = true | ||||
|      | ||||
|     private let repository = LiveRepository() | ||||
|     private var subscription = Set<AnyCancellable>() | ||||
|      | ||||
|     var reservationDate = Date() { | ||||
|         willSet { | ||||
|             reservationDateString = newValue.convertDateFormat(dateFormat: "yyyy.MM.dd") | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     var reservationTime = Date() { | ||||
|         willSet { | ||||
|             reservationTimeString = newValue.convertDateFormat(dateFormat: "a hh : mm") | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     var coverImagePath: String? = nil | ||||
|      | ||||
|     let placeholder = "라이브 공지를 입력하세요" | ||||
|      | ||||
|     func getRecentInfo() { | ||||
|         isLoading = true | ||||
|          | ||||
|         repository.getRecentRoomInfo() | ||||
|             .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 | ||||
|                  | ||||
|                 DispatchQueue.global(qos: .background).async { | ||||
|                     do { | ||||
|                         let jsonDecoder = JSONDecoder() | ||||
|                         let decoded = try jsonDecoder.decode(ApiResponse<GetRecentRoomInfoResponse>.self, from: responseData) | ||||
|                          | ||||
|                         if let data = decoded.data, decoded.success { | ||||
|                             DispatchQueue.main.async { | ||||
|                                 self.title = data.title | ||||
|                                 self.content = data.notice | ||||
|                                 self.coverImageUrl = data.coverImageUrl | ||||
|                                 self.coverImagePath = data.coverImagePath | ||||
|                                 self.numberOfPeople = String(data.numberOfPeople) | ||||
|                                  | ||||
|                                 self.errorMessage = "최근데이터를 불러왔습니다." | ||||
|                                 self.isShowPopup = true | ||||
|                                 self.isShowGetRecentInfoButton = false | ||||
|                             } | ||||
|                         } else { | ||||
|                             DispatchQueue.main.async { | ||||
|                                 if let message = decoded.message { | ||||
|                                     self.errorMessage = message | ||||
|                                 } else { | ||||
|                                     self.errorMessage = "최근데이터를 불러오지 못했습니다.\n다시 시도해 주세요." | ||||
|                                 } | ||||
|                                  | ||||
|                                 self.isShowPopup = true | ||||
|                             } | ||||
|                         } | ||||
|                     } catch { | ||||
|                         print(error) | ||||
|                         DispatchQueue.main.async { | ||||
|                             self.errorMessage = "최근데이터를 불러오지 못했습니다.\n다시 시도해 주세요." | ||||
|                             self.isShowPopup = true | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
|      | ||||
|     func createRoom(onSuccess: @escaping (CreateLiveRoomResponse) -> Void) { | ||||
|         if !isLoading && validate() { | ||||
|             isLoading = true | ||||
|              | ||||
|             var request = CreateLiveRoomRequest( | ||||
|                 title: title, | ||||
|                 content: content.trimmingCharacters(in: .whitespacesAndNewlines) != placeholder ? content : "", | ||||
|                 coverImageUrl: coverImagePath, | ||||
|                 tags: tags, | ||||
|                 numberOfPeople: Int(numberOfPeople)!, | ||||
|                 isAdult: isAdult, | ||||
|                 price: price, | ||||
|                 type: roomType, | ||||
|                 password: (roomType == .PRIVATE && !password.trimmingCharacters(in: .whitespaces).isEmpty) ? password : nil | ||||
|             ) | ||||
|              | ||||
|             if timeSettingMode == .RESERVATION && roomType != .SECRET { | ||||
|                 request.beginDateTimeString = "\(reservationDate.convertDateFormat(dateFormat: "yyyy-MM-dd")) \(reservationTime.convertDateFormat(dateFormat: "HH:mm"))" | ||||
|             } | ||||
|              | ||||
|             var multipartData = [MultipartFormData]() | ||||
|              | ||||
|             let encoder = JSONEncoder() | ||||
|             encoder.outputFormatting = .withoutEscapingSlashes | ||||
|             let jsonData = try? encoder.encode(request) | ||||
|              | ||||
|             if let jsonData = jsonData { | ||||
|                  | ||||
|                 if let coverImage = coverImage, let imageData = coverImage.jpegData(compressionQuality: 0.8) { | ||||
|                     multipartData.append( | ||||
|                         MultipartFormData( | ||||
|                             provider: .data(imageData), | ||||
|                             name: "coverImage", | ||||
|                             fileName: "\(UUID().uuidString)_\(Date().timeIntervalSince1970 * 1000).jpg", | ||||
|                             mimeType: "image/*") | ||||
|                     ) | ||||
|                 } | ||||
|                  | ||||
|                 multipartData.append(MultipartFormData(provider: .data(jsonData), name: "request")) | ||||
|                  | ||||
|                 repository | ||||
|                     .createRoom(parameters: multipartData) | ||||
|                     .sink { result in | ||||
|                         switch result { | ||||
|                         case .finished: | ||||
|                             DEBUG_LOG("finish") | ||||
|                         case .failure(let error): | ||||
|                             ERROR_LOG(error.localizedDescription) | ||||
|                         } | ||||
|                     } receiveValue: { response in | ||||
|                         self.isLoading = false | ||||
|                         let responseData = response.data | ||||
|                          | ||||
|                         do { | ||||
|                             let jsonDecoder = JSONDecoder() | ||||
|                             let decoded = try jsonDecoder.decode(ApiResponse<CreateLiveRoomResponse>.self, from: responseData) | ||||
|                              | ||||
|                             if let data = decoded.data, decoded.success { | ||||
|                                 onSuccess(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) | ||||
|             } else { | ||||
|                 self.errorMessage = "라이브을 만들지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                 self.isShowPopup = true | ||||
|                 self.isLoading = false | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func validate() -> Bool { | ||||
|         if coverImage == nil && coverImagePath == nil { | ||||
|             self.errorMessage = "커버이미지를 선택해주세요." | ||||
|             self.isShowPopup = true | ||||
|             return false | ||||
|         } | ||||
|          | ||||
|         if title.trimmingCharacters(in: .whitespaces).isEmpty { | ||||
|             self.errorMessage = "제목을 입력해 주세요." | ||||
|             self.isShowPopup = true | ||||
|             return false | ||||
|         } | ||||
|          | ||||
|         let notice = content.trimmingCharacters(in: .whitespacesAndNewlines) != placeholder ? content : "" | ||||
|         if notice.isEmpty && notice.count < 5 { | ||||
|             self.errorMessage = "공지를 5자 이상 입력해주세요." | ||||
|             self.isShowPopup = true | ||||
|             return false | ||||
|         } | ||||
|          | ||||
|         guard let numberOfPeople = Int(numberOfPeople), (numberOfPeople >= 3 && numberOfPeople <= 999) else { | ||||
|             self.errorMessage = "인원을 3~999명 사이로 입력해주세요." | ||||
|             self.isShowPopup = true | ||||
|             return false | ||||
|         } | ||||
|          | ||||
|         if roomType == .PRIVATE && (password.trimmingCharacters(in: .whitespaces).isEmpty || password.count != 6) { | ||||
|             self.errorMessage = "방 입장 비밀번호 6자리를 입력해 주세요." | ||||
|             self.isShowPopup = true | ||||
|             return false | ||||
|         } | ||||
|          | ||||
|         return true | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| // | ||||
| //  GetLiveRoomTagResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| struct GetLiveRoomTagResponse: Decodable, Hashable { | ||||
|     let id: Int | ||||
|     let tag: String | ||||
|     let image: String | ||||
| } | ||||
| @@ -0,0 +1,20 @@ | ||||
| // | ||||
| //  LiveRoomCreateTagRepository.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import CombineMoya | ||||
| import Combine | ||||
| import Moya | ||||
|  | ||||
| final class LiveRoomCreateTagRepository { | ||||
|     private let api = MoyaProvider<LiveApi>() | ||||
|      | ||||
|     func getTags() -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getTags) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -0,0 +1,118 @@ | ||||
| // | ||||
| //  LiveRoomCreateTagView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct LiveRoomCreateTagView: View { | ||||
|      | ||||
|     let columns = [ | ||||
|         GridItem(.flexible()), | ||||
|         GridItem(.flexible()), | ||||
|         GridItem(.flexible()), | ||||
|         GridItem(.flexible()) | ||||
|     ] | ||||
|      | ||||
|     @StateObject var viewModel = LiveRoomCreateTagViewModel() | ||||
|      | ||||
|     @Binding var isShowing: Bool | ||||
|     @Binding var selectedTags: [String] | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             Color(hex: "222222").ignoresSafeArea() | ||||
|              | ||||
|             VStack(spacing: 0) { | ||||
|                 HStack(alignment: .top, spacing: 0) { | ||||
|                     VStack(alignment: .leading, spacing: 6.7) { | ||||
|                         Text("관심사 선택") | ||||
|                             .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                             .foregroundColor(.white) | ||||
|                          | ||||
|                         Text("최대 3개까지 선택 가능합니다.") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                             .foregroundColor(Color(hex: "777777")) | ||||
|                     } | ||||
|                      | ||||
|                     Spacer() | ||||
|                      | ||||
|                     Image("ic_close_white") | ||||
|                         .resizable() | ||||
|                         .frame(width: 20, height: 20) | ||||
|                         .onTapGesture { | ||||
|                             isShowing = false | ||||
|                         } | ||||
|                 } | ||||
|                 .padding(.horizontal, 26.7) | ||||
|                 .padding(.top, 26.7) | ||||
|                  | ||||
|                 ScrollView(.vertical, showsIndicators: false) { | ||||
|                     LazyVGrid(columns: columns, spacing: 26.7) { | ||||
|                         ForEach(viewModel.tags, id: \.self) { tag in | ||||
|                             VStack(spacing: 16.7) { | ||||
|                                 ZStack { | ||||
|                                     KFImage(URL(string: tag.image)) | ||||
|                                         .resizable() | ||||
|                                         .scaledToFill() | ||||
|                                         .frame(width: 60, height: 60, alignment: .top) | ||||
|                                         .clipped() | ||||
|                                      | ||||
|                                     if selectedTags.contains(tag.tag) { | ||||
|                                         Image("ic_tag_check") | ||||
|                                             .resizable() | ||||
|                                             .frame(width: 60, height: 60) | ||||
|                                     } | ||||
|                                 } | ||||
|                                  | ||||
|                                 Text(tag.tag) | ||||
|                                     .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                                     .foregroundColor( | ||||
|                                         selectedTags.contains(tag.tag) ? | ||||
|                                         Color(hex: "9970ff") : | ||||
|                                             Color(hex: "bbbbbb") | ||||
|                                     ) | ||||
|                             } | ||||
|                             .onTapGesture { | ||||
|                                 if selectedTags.contains(tag.tag) { | ||||
|                                     if let index = selectedTags.firstIndex(of: tag.tag) { | ||||
|                                         selectedTags.remove(at: index) | ||||
|                                     } | ||||
|                                 } else { | ||||
|                                     if selectedTags.count < 3 { | ||||
|                                         selectedTags.append(tag.tag) | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 .padding(.horizontal, 20) | ||||
|                 .padding(.top, 26.7) | ||||
|                  | ||||
|                 Text("확인") | ||||
|                     .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                     .foregroundColor(.white) | ||||
|                     .padding(.vertical, 16) | ||||
|                     .frame(width: screenSize().width - 26.7) | ||||
|                     .background(Color(hex: "9970ff")) | ||||
|                     .cornerRadius(10) | ||||
|                     .padding(.bottom, 26.7) | ||||
|                     .onTapGesture { | ||||
|                         isShowing = false | ||||
|                     } | ||||
|             } | ||||
|              | ||||
|             if viewModel.isLoading { | ||||
|                 LoadingView() | ||||
|             } | ||||
|         } | ||||
|         .cornerRadius(16.7, corners: [.topLeft, .topRight]) | ||||
|         .onAppear { | ||||
|             viewModel.getTags() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,60 @@ | ||||
| // | ||||
| //  LiveRoomCreateTagViewModel.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import Combine | ||||
|  | ||||
| final class LiveRoomCreateTagViewModel: ObservableObject { | ||||
|      | ||||
|     private let repository = LiveRoomCreateTagRepository() | ||||
|     private var subscription = Set<AnyCancellable>() | ||||
|      | ||||
|     @Published var isLoading = false | ||||
|     @Published var errorMessage = "" | ||||
|     @Published var isShowPopup = false | ||||
|     @Published var tags: [GetLiveRoomTagResponse] = [] | ||||
|      | ||||
|     func getTags() { | ||||
|         isLoading = true | ||||
|          | ||||
|         repository.getTags() | ||||
|             .sink { result in | ||||
|                 switch result { | ||||
|                 case .finished: | ||||
|                     DEBUG_LOG("finish") | ||||
|                 case .failure(let error): | ||||
|                     ERROR_LOG(error.localizedDescription) | ||||
|                 } | ||||
|             } receiveValue: { response in | ||||
|                 self.isLoading = false | ||||
|                 let responseData = response.data | ||||
|                  | ||||
|                 do { | ||||
|                     let jsonDecoder = JSONDecoder() | ||||
|                     let decoded = try jsonDecoder.decode(ApiResponse<[GetLiveRoomTagResponse]>.self, from: responseData) | ||||
|                      | ||||
|                     if let data = decoded.data, decoded.success { | ||||
|                         self.tags.removeAll() | ||||
|                         self.tags.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) | ||||
|  | ||||
|     } | ||||
| } | ||||
							
								
								
									
										13
									
								
								SodaLive/Sources/Live/StartLiveRequest.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								SodaLive/Sources/Live/StartLiveRequest.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| // | ||||
| //  StartLiveRequest.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct StartLiveRequest: Encodable { | ||||
|     let roomId: Int | ||||
|     let timezone: String = TimeZone.current.identifier | ||||
| } | ||||
		Reference in New Issue
	
	Block a user
	 Yu Sung
					Yu Sung