라이브 상세 페이지 추가

This commit is contained in:
Yu Sung 2023-08-14 19:22:23 +09:00
parent e0a5fb733d
commit 634f50d4f2
37 changed files with 2767 additions and 49 deletions

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "btn_big_share.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ic_avatar.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ic_mic_colored.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -76,4 +76,23 @@ enum AppStep {
timeSettingMode: LiveRoomCreateViewModel.TimeSettingMode,
onSuccess: (CreateLiveRoomResponse) -> Void
)
case liveNowAll(onClickParticipant: (Int) -> Void)
case liveReservationAll(
onClickReservation: (Int) -> Void,
onClickStart: (Int) -> Void,
onClickCancel: () -> Void,
onTapCreateLive: () -> Void
)
case modifyLive(room: GetRoomDetailResponse)
case liveDetail(
roomId: Int,
onClickParticipant: () -> Void,
onClickReservation: () -> Void,
onClickStart: () -> Void,
onClickCancel: () -> Void
)
}

View File

@ -110,6 +110,29 @@ struct ContentView: View {
onSuccess: onSuccess
)
case .liveNowAll(let onClickParticipant):
LiveNowAllView(onClickParticipant: onClickParticipant)
case .liveReservationAll(let onClickReservation, let onClickStart, let onClickCancel, let onTapCreateLive):
LiveReservationAllView(
onClickReservation: onClickReservation,
onClickStart: onClickStart,
onClickCancel: onClickCancel,
onTapCreateLive: onTapCreateLive
)
case .modifyLive(let room):
LiveRoomEditView(room: room)
case .liveDetail(let roomId, let onClickParticipant, let onClickReservation, let onClickStart, let onClickCancel):
LiveDetailView(
roomId: roomId,
onClickParticipant: onClickParticipant,
onClickReservation: onClickReservation,
onClickStart: onClickStart,
onClickCancel: onClickCancel
)
default:
EmptyView()
.frame(width: 0, height: 0, alignment: .topLeading)

View File

@ -12,7 +12,7 @@ struct LiveRoomPasswordDialog: View {
@Binding var isShowing: Bool
let can: Int
let confirmAction: (Int) -> Void
let confirmAction: (String) -> Void
@State private var password = ""
@StateObject var keyboardHandler = KeyboardHandler()
@ -81,9 +81,9 @@ struct LiveRoomPasswordDialog: View {
.cornerRadius(8)
.onTapGesture {
if password.trimmingCharacters(in: .whitespaces).isEmpty {
confirmAction(0)
confirmAction("")
} else {
confirmAction(Int(password)!)
confirmAction(password)
}
isShowing = false
}
@ -97,9 +97,9 @@ struct LiveRoomPasswordDialog: View {
.cornerRadius(8)
.onTapGesture {
if password.trimmingCharacters(in: .whitespaces).isEmpty {
confirmAction(0)
confirmAction("")
} else {
confirmAction(Int(password)!)
confirmAction(password)
}
isShowing = false
}

View File

@ -30,13 +30,9 @@ final class UserProfileViewModel: ObservableObject {
@Published var paymentDialogConfirmAction = {}
@Published var paymentDialogConfirmTitle = ""
@Published var secretDialogManagerNickname = ""
@Published var secretDialogConfirmAction = {}
@Published var isShowSecretDialog = false
@Published var secretOrPasswordDialogCan = 0
@Published var passwordDialogConfirmAction: (Int) -> Void = { _ in }
@Published var passwordDialogConfirmAction: (String) -> Void = { _ in }
@Published var isShowPasswordDialog = false
@Published var navigationTitle = "채널"
@ -101,7 +97,6 @@ final class UserProfileViewModel: ObservableObject {
func hidePaymentPopup() {
isShowPaymentDialog = false
isShowSecretDialog = false
isShowPasswordDialog = false
paymentDialogTitle = ""
@ -109,9 +104,6 @@ final class UserProfileViewModel: ObservableObject {
paymentDialogConfirmAction = {}
secretOrPasswordDialogCan = 0
secretDialogManagerNickname = ""
secretDialogConfirmAction = {}
passwordDialogConfirmAction = { _ in }
}
@ -144,7 +136,7 @@ final class UserProfileViewModel: ObservableObject {
}
}
private func reservation(roomId: Int, password: Int? = nil) {
private func reservation(roomId: Int, password: String? = nil) {
isLoading = true
let request = MakeLiveReservationRequest(roomId: roomId, password: password)
liveRepository.makeReservation(request: request)
@ -190,14 +182,7 @@ final class UserProfileViewModel: ObservableObject {
self.enterRoom(roomId: roomId)
}
} else if ($0.price == 0 || $0.isPaid) {
if $0.isSecretRoom {
self.secretDialogManagerNickname = $0.manager.nickname
self.secretDialogConfirmAction = {
self.enterRoom(roomId: roomId)
}
self.secretOrPasswordDialogCan = 0
self.isShowSecretDialog = true
} else if $0.isPrivateRoom {
if $0.isPrivateRoom {
self.passwordDialogConfirmAction = { password in
self.enterRoom(roomId: roomId, password: password)
}
@ -208,14 +193,7 @@ final class UserProfileViewModel: ObservableObject {
}
}
} else {
if $0.isSecretRoom {
self.secretDialogManagerNickname = $0.manager.nickname
self.secretDialogConfirmAction = {
self.enterRoom(roomId: roomId)
}
self.secretOrPasswordDialogCan = $0.price
self.isShowSecretDialog = true
} else if $0.isPrivateRoom {
if $0.isPrivateRoom {
self.secretOrPasswordDialogCan = $0.price
self.passwordDialogConfirmAction = { password in
self.enterRoom(roomId: roomId, password: password)
@ -236,7 +214,7 @@ final class UserProfileViewModel: ObservableObject {
}
}
func enterRoom(roomId: Int, onSuccess: (() -> Void)? = nil, password: Int? = nil) {
func enterRoom(roomId: Int, onSuccess: (() -> Void)? = nil, password: String? = nil) {
isLoading = true
let request = EnterOrQuitLiveRoomRequest(roomId: roomId, password: password)
liveRepository.enterRoom(request: request)

View File

@ -0,0 +1,13 @@
//
// CancelLiveRequest.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import Foundation
struct CancelLiveRequest: Encodable {
let roomId: Int
let reason: String
}

View File

@ -0,0 +1,77 @@
//
// LiveCancelDialog.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import SwiftUI
struct LiveCancelDialog: View {
@Binding var isShowCancelPopup: Bool
let confirmAction: (String) -> Void
@State var reason: String = ""
var placeholder = "취소사유를 입력하세요"
var body: some View {
VStack(spacing: 0) {
Text("예약취소")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color(hex: "bbbbbb"))
.padding(.top, 40)
TextViewWrapper(
text: $reason,
placeholder: placeholder,
textColorHex: "eeeeee",
backgroundColorHex: "333333"
)
.frame(width: screenSize().width - 53.4, height: 150)
.cornerRadius(6.7)
.overlay(
RoundedRectangle(cornerRadius: 6.7)
.stroke(Color(hex: "9970ff"), lineWidth: 1.3)
)
.padding(.top, 13.3)
HStack(spacing: 13.3) {
Text("취소")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color(hex: "9970ff"))
.padding(.vertical, 16)
.padding(.horizontal, 48)
.background(Color(hex: "9970ff").opacity(0.2))
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color(hex: "9970ff"), lineWidth: 1.3)
)
.onTapGesture {
isShowCancelPopup = false
}
Text("확인")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(.white)
.padding(.vertical, 16)
.padding(.horizontal, 48)
.background(Color(hex: "9970ff"))
.cornerRadius(10)
.onTapGesture {
confirmAction(reason.trimmingCharacters(in: .whitespacesAndNewlines) != placeholder ? reason : "")
isShowCancelPopup = false
}
}
.padding(.top, 45)
.padding(.bottom, 16.7)
}
.frame(width: screenSize().width - 26.7)
.background(Color(hex: "222222"))
.cornerRadius(10)
.onAppear {
UITextView.appearance().backgroundColor = .clear
}
}
}

View File

@ -21,6 +21,8 @@ enum LiveApi {
case getRecentRoomInfo
case createRoom(parameters: [MultipartFormData])
case startLive(request: StartLiveRequest)
case cancelRoom(request: CancelLiveRequest)
case editLiveRoomInfo(roomId: Int, parameters: [MultipartFormData])
}
extension LiveApi: TargetType {
@ -65,6 +67,12 @@ extension LiveApi: TargetType {
case .startLive:
return "/live/room/start"
case .cancelRoom:
return "/live/room/cancel"
case .editLiveRoomInfo(let roomId, _):
return "/live/room/\(roomId)"
}
}
@ -76,7 +84,7 @@ extension LiveApi: TargetType {
case .makeReservation, .enterRoom, .createRoom:
return .post
case .cancelReservation, .startLive:
case .cancelReservation, .startLive, .cancelRoom, .editLiveRoomInfo:
return .put
}
}
@ -139,6 +147,12 @@ extension LiveApi: TargetType {
case .startLive(let request):
return .requestJSONEncodable(request)
case .cancelRoom(let request):
return .requestJSONEncodable(request)
case .editLiveRoomInfo(_, let parameters):
return .uploadMultipart(parameters)
}
}

View File

@ -56,4 +56,12 @@ final class LiveRepository {
func startLive(roomId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.startLive(request: StartLiveRequest(roomId: roomId)))
}
func cancelRoom(roomId: Int, reason: String) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.cancelRoom(request: CancelLiveRequest(roomId: roomId, reason: reason)))
}
func editLiveRoomInfo(roomId: Int, parameters: [MultipartFormData]) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.editLiveRoomInfo(roomId: roomId, parameters: parameters))
}
}

View File

@ -73,19 +73,22 @@ struct LiveView: View {
height: viewModel.eventBannerItems.count > 0 ? screenSize().width * 300 / 1000 : 0,
alignment: .center
)
.padding(.vertical, 40)
.padding(.top, 40)
}
if viewModel.liveReservationItems.count > 0 {
SectionLiveReservationView(
items: viewModel.liveReservationItems,
onClickCancel: { viewModel.getSummary() },
onClickStart: {_ in},
onClickReservation: {_ in},
onClickStart: { roomId in processStart(roomId: roomId) },
onClickReservation: { roomId in
viewModel.reservationLiveRoom(roomId: roomId)
},
onTapCreateLive: {
AppState.shared.setAppStep(step: .createLive(timeSettingMode: .RESERVATION, onSuccess: onCreateSuccess))
}
)
.padding(.top, 40)
}
}
}

View File

@ -37,7 +37,7 @@ final class LiveViewModel: ObservableObject {
@Published var paymentDialogConfirmTitle = ""
@Published var secretOrPasswordDialogCoin = 0
@Published var passwordDialogConfirmAction: (Int) -> Void = { _ in }
@Published var passwordDialogConfirmAction: (String) -> Void = { _ in }
@Published var isShowPasswordDialog = false
@Published var isFollowingList = UserDefaults.bool(forKey: .isFollowedChannel) {
@ -52,6 +52,17 @@ final class LiveViewModel: ObservableObject {
var isLast = false
private let pageSize = 10
var selectedDateString: String = "" {
didSet {
if !selectedDateString.trimmingCharacters(in: .whitespaces).isEmpty {
page = 1
isLast = false
liveReservationItems.removeAll()
getLiveReservationList()
}
}
}
func hidePopup() {
isShowPaymentDialog = false
isShowPasswordDialog = false
@ -176,7 +187,7 @@ final class LiveViewModel: ObservableObject {
.store(in: &subscription)
}
func enterRoom(roomId: Int, onSuccess: (() -> Void)? = nil, password: Int? = nil) {
func enterRoom(roomId: Int, onSuccess: (() -> Void)? = nil, password: String? = nil) {
isLoading = true
let request = EnterOrQuitLiveRoomRequest(roomId: roomId, password: password)
repository.enterRoom(request: request)
@ -260,6 +271,216 @@ final class LiveViewModel: ObservableObject {
.store(in: &subscription)
}
func getLiveNowList() {
if (!isLast && !isLoading) {
isLoading = true
repository.roomList(
request: GetRoomListRequest(
timezone: TimeZone.current.identifier,
dateString: nil,
status: .NOW,
page: page,
size: pageSize
)
)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
self.isLoading = false
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<[GetRoomListResponse]>.self, from: responseData)
if let data = decoded.data, decoded.success {
if !data.isEmpty {
page += 1
self.liveNowItems.append(contentsOf: data)
} else {
isLast = 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 getLiveReservationList() {
if (!isLast && !isLoading) {
isLoading = true
repository.roomList(
request: GetRoomListRequest(
timezone: TimeZone.current.identifier,
dateString: selectedDateString,
status: .RESERVATION,
page: page,
size: pageSize
)
)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
self.isLoading = false
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<[GetRoomListResponse]>.self, from: responseData)
if let data = decoded.data, decoded.success {
if !data.isEmpty {
page += 1
self.liveReservationItems.append(contentsOf: data)
} else {
isLast = 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 reservationLiveRoom(roomId: Int) {
getRoomDetail(roomId: roomId) { [unowned self] in
if ($0.manager.id == UserDefaults.int(forKey: .userId)) {
self.errorMessage = "내가 만든 라이브는 예약할 수 없습니다."
self.isShowPopup = true
} else {
if $0.isPrivateRoom {
self.passwordDialogConfirmAction = { password in
self.reservation(roomId: roomId, password: password)
}
self.isShowPasswordDialog = true
} else {
if ($0.price == 0 || $0.isPaid) {
self.reservation(roomId: roomId)
} else {
self.paymentDialogTitle = "\($0.price)코인으로 예약"
self.paymentDialogDesc = "'\($0.title)' 라이브에 참여하기 위해 결제합니다."
self.paymentDialogConfirmTitle = "결제 후 예약하기"
self.paymentDialogConfirmAction = { [unowned self] in
hidePopup()
reservation(roomId: roomId)
}
self.isShowPaymentDialog = true
}
}
}
}
}
private func getRoomDetail(roomId: Int, onSuccess: @escaping (GetRoomDetailResponse) -> Void) {
isLoading = true
repository.getRoomDetail(roomId: roomId)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { response in
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<GetRoomDetailResponse>.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
}
self.isLoading = false
}
.store(in: &subscription)
}
private func reservation(roomId: Int, password: String? = nil) {
isLoading = true
let request = MakeLiveReservationRequest(roomId: roomId, password: password)
repository.makeReservation(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(ApiResponse<MakeLiveReservationResponse>.self, from: responseData)
if let response = decoded.data, decoded.success {
self.getSummary()
AppState.shared.setAppStep(step: .liveReservationComplete(response: response))
} 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

View File

@ -0,0 +1,13 @@
//
// LiveAllViewModel.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import Foundation
final class LiveAllViewModel: ObservableObject {
@Published var isShowLiveDetail = false
@Published var selectedRoomId = 0
}

View File

@ -0,0 +1,117 @@
//
// LiveNowAllItemView.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import SwiftUI
import Kingfisher
struct LiveNowAllItemView: View {
let item: GetRoomListResponse
var body: some View {
VStack(spacing: 13.3) {
HStack(spacing: 20) {
ZStack(alignment: .topLeading) {
KFImage(URL(string: item.coverImageUrl))
.resizable()
.scaledToFill()
.frame(width: 80, height: 116.7, alignment: .top)
.cornerRadius(4.7)
.clipped()
if item.isAdult {
Text("19")
.font(.custom(Font.bold.rawValue, size: 11.3))
.foregroundColor(Color.white)
.padding(4)
.background(Color(hex: "e53621"))
.cornerRadius(20)
.padding(.top, 3.3)
.padding(.leading, 3.3)
}
}
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
Text(item.managerNickname)
.font(.custom(Font.medium.rawValue, size: 11.3))
.foregroundColor(Color(hex: "bbbbbb"))
Text(item.title)
.font(.custom(Font.medium.rawValue, size: 15.3))
.foregroundColor(Color(hex: "e2e2e2"))
.lineLimit(2)
.padding(.top, 4.3)
.padding(.trailing, 20)
}
Spacer()
if item.isPrivateRoom {
Image("ic_lock")
.resizable()
.frame(width: 20, height: 20)
}
}
.padding(.top, 13.3)
Spacer()
HStack(spacing: 0) {
Image("ic_avatar")
.resizable()
.frame(width: 20, height: 20)
Text("\(item.numberOfParticipate)")
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(.white)
.padding(.leading, 2.7)
Text("/\(item.numberOfPeople)")
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(Color(hex: "555555"))
Text(item.numberOfPeople > item.numberOfParticipate ? "참여가능" : "Sold out")
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(
Color(
hex: item.numberOfPeople > item.numberOfParticipate ?
"9970ff" :
"ffd300"
)
)
.padding(.leading, 10)
Spacer()
if item.price > 0 {
Text("\(item.price)")
.font(.custom(Font.bold.rawValue, size: 15.3))
.foregroundColor(Color(hex: "eeeeee"))
Image("ic_can")
.resizable()
.frame(width: 20, height: 20)
.padding(.leading, 6.7)
} else {
Text("무료")
.font(.custom(Font.bold.rawValue, size: 15.3))
.foregroundColor(Color(hex: "eeeeee"))
}
}
.padding(.bottom, 3.3)
}
}
Rectangle()
.foregroundColor(Color(hex: "909090").opacity(0.5))
.frame(width: screenSize().width - 26.7, height: 1)
}
.frame(width: screenSize().width - 26.7, height: 130, alignment: .center)
}
}

View File

@ -0,0 +1,83 @@
//
// LiveNowAllView.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import SwiftUI
import RefreshableScrollView
struct LiveNowAllView: View {
@StateObject var viewModel = LiveViewModel()
@StateObject var liveAllViewModel = LiveAllViewModel()
let onClickParticipant: (Int) -> Void
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
GeometryReader { proxy in
VStack(spacing: 0) {
DetailNavigationBar(title: "지금 라이브 중 전체보기")
RefreshableScrollView(
refreshing: $viewModel.isRefresh,
action: {
viewModel.getLiveNowList()
},
content: {
VStack(spacing: 0) {
ForEach(0..<viewModel.liveNowItems.count, id: \.self) { index in
let item = viewModel.liveNowItems[index]
LiveNowAllItemView(item: item)
.contentShape(Rectangle())
.onTapGesture {
self.liveAllViewModel.selectedRoomId = item.roomId
withAnimation {
self.liveAllViewModel.isShowLiveDetail = true
}
}
.onAppear {
if index == viewModel.liveNowItems.count - 1 {
viewModel.getLiveNowList()
}
}
}
}
}
)
if proxy.safeAreaInsets.bottom > 0 {
Rectangle()
.foregroundColor(Color.black.opacity(0))
.frame(width: screenSize().width, height: 15.3)
}
}
.edgesIgnoringSafeArea(.bottom)
}
if liveAllViewModel.isShowLiveDetail {
LiveDetailView(
roomId: liveAllViewModel.selectedRoomId,
onClickParticipant: {
AppState.shared.isShowPlayer = false
onClickParticipant(liveAllViewModel.selectedRoomId)
},
onClickReservation: {},
onClickStart: {},
onClickCancel: {},
onClickClose: {
withAnimation {
liveAllViewModel.isShowLiveDetail = false
}
}
)
}
}
.onAppear {
viewModel.getLiveNowList()
}
}
}

View File

@ -0,0 +1,111 @@
//
// LiveNowItemView.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import SwiftUI
import Kingfisher
struct LiveNowItemView: View {
let item: GetRoomListResponse
let width: CGFloat = 133.3
let height: CGFloat = 176.7
var body: some View {
ZStack {
KFImage(URL(string: item.coverImageUrl))
.resizable()
.scaledToFill()
.frame(width: width, height: height, alignment: .top)
.cornerRadius(4.7)
.clipped()
LinearGradient(
colors: [Color.black.opacity(0.1), Color.black.opacity(0.8)],
startPoint: .top,
endPoint: .bottom
)
VStack(spacing: 0) {
HStack(spacing: 3.3) {
Text(item.price > 0 ? "유료" : "무료")
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color.white)
.padding(.horizontal, 7.3)
.padding(.vertical, 4)
.background(Color(hex: item.price > 0 ? "881609" : "643bc8"))
.cornerRadius(10)
Spacer()
if item.isPrivateRoom {
Image("ic_lock")
.resizable()
.frame(width: 20, height: 20)
}
if item.isAdult {
Text("19")
.font(.custom(Font.bold.rawValue, size: 11.3))
.foregroundColor(Color.white)
.padding(4)
.background(Color(hex: "e53621"))
.clipShape(Circle())
}
}
.padding(.horizontal, 3.3)
.padding(.top, 3.3)
Spacer()
HStack(spacing: 0) {
Image("ic_avatar")
.resizable()
.frame(width: 20, height: 20)
Text("\(item.numberOfParticipate)")
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(Color.white)
.padding(.leading, 2.7)
Spacer()
Text("\(item.managerNickname)")
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(Color.white)
}
.padding(.horizontal, 6.7)
.padding(.bottom, 6.7)
}
}
.frame(width: width, height: height)
}
}
struct LiveNowItemView_Previews: PreviewProvider {
static var previews: some View {
LiveNowItemView(
item: GetRoomListResponse(
roomId: 99,
title: "test",
content: "testtest",
beginDateTime: "2022.05.23 Mon 03:00 PM",
numberOfParticipate: 3,
numberOfPeople: 5,
coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png",
isAdult: true,
price: 0,
tags: ["팬미팅", "힐링"],
channelName: nil,
managerNickname: "user8",
managerId: 19,
isReservation: false,
isPrivateRoom: true
)
)
}
}

View File

@ -16,6 +16,88 @@ struct SectionLiveNowView: View {
var body: some View {
VStack(spacing: 13.3) {
HStack(spacing: 0) {
Text("지금 ")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color(hex: "eeeeee"))
Text("라이브중")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color(hex: "ff5c49"))
Spacer()
if items.count > 0 {
Text("전체보기")
.font(.custom(Font.light.rawValue, size: 11.3))
.foregroundColor(Color(hex: "bbbbbb"))
.onTapGesture { AppState.shared.setAppStep(step: .liveNowAll(onClickParticipant: onClickParticipant)) }
}
}
.padding(.horizontal, 13.3)
.frame(width: screenSize().width)
if items.count > 0 {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(items, id: \.self) { item in
LiveNowItemView(item: item)
.contentShape(Rectangle())
.onTapGesture {
AppState.shared.setAppStep(
step: .liveDetail(
roomId: item.roomId,
onClickParticipant: {
AppState.shared.isShowPlayer = false
onClickParticipant(item.roomId)
},
onClickReservation: {},
onClickStart: {
},
onClickCancel: {
}
)
)
}
}
}
.padding(.horizontal, 13.3)
}
.padding(.top, 28.3)
} else {
VStack(spacing: 0) {
Image("ic_no_item")
.resizable()
.frame(width: 60, height: 60)
Text("🙀지금 참여가능한 라이브가 없습니다.\n직접 라이브를 만들어 보세요!")
.font(.custom(Font.medium.rawValue, size: 10.7))
.foregroundColor(Color(hex: "bbbbbb"))
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.center)
.padding(.top, 8)
HStack(spacing: 0) {
Image("ic_plus_no_bg")
.resizable()
.frame(width: 33.3, height: 33.3, alignment: .center)
Text("라이브 만들기")
.font(.custom(Font.bold.rawValue, size: 13.3))
.foregroundColor(Color.white)
}
.frame(width: 200, height: 33.3, alignment: .center)
.background(Color(hex: "9970ff"))
.cornerRadius(4.7)
.padding(.top, 10.7)
.onTapGesture { onTapCreateLive() }
}
.padding(.vertical, 16.7)
.frame(width: screenSize().width - 26.7)
.background(Color(hex: "2b2635"))
.cornerRadius(4.7)
.padding(.top, 28.3)
}
}
}
}

View File

@ -0,0 +1,14 @@
//
// DateWithWeekDaySymbol.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import Foundation
struct DateWithWeekDaySymbol {
let date: String
let dayOfMonth: String
let weekDaySymbol: String
}

View File

@ -0,0 +1,91 @@
//
// LiveReservationAllItemView.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import SwiftUI
import Kingfisher
struct LiveReservationAllItemView: View {
let item: GetRoomListResponse
var body: some View {
VStack(spacing: 13.3) {
HStack(spacing: 20) {
ZStack(alignment: .topLeading) {
KFImage(URL(string: item.coverImageUrl))
.resizable()
.scaledToFill()
.frame(width: 80, height: 116.7, alignment: .top)
.cornerRadius(4.7)
.clipped()
if item.isAdult {
Text("19")
.font(.custom(Font.bold.rawValue, size: 11.3))
.foregroundColor(Color.white)
.padding(4)
.background(Color(hex: "e53621"))
.cornerRadius(20)
.padding(.top, 3.3)
.padding(.leading, 3.3)
}
}
HStack(alignment: .top, spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
Text(item.beginDateTime)
.font(.custom(Font.medium.rawValue, size: 9.3))
.foregroundColor(Color(hex: "ffd300"))
Text(item.managerNickname)
.font(.custom(Font.medium.rawValue, size: 11.3))
.foregroundColor(Color(hex: "bbbbbb"))
.padding(.top, 10)
Text(item.title)
.font(.custom(Font.medium.rawValue, size: 15.3))
.foregroundColor(Color(hex: "e2e2e2"))
.lineLimit(2)
.padding(.top, 10)
.padding(.trailing, 20)
Spacer()
if item.isReservation {
Text("예약완료")
.font(.custom(Font.medium.rawValue, size: 11.3))
.foregroundColor(Color(hex: "d2d2d2"))
.padding(.horizontal, 7)
.padding(.vertical, 4)
.background(Color(hex: "533d89"))
.cornerRadius(10)
} else {
Text(item.price > 0 ? "\(item.price)" : "무료")
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color(hex: "e2e2e2").opacity(0.49))
.padding(.bottom, 6.7)
}
}
Spacer()
if item.isPrivateRoom {
Image("ic_lock")
.resizable()
.frame(width: 20, height: 20)
}
}
.padding(.vertical, 6.7)
}
Rectangle()
.foregroundColor(Color(hex: "909090").opacity(0.5))
.frame(width: screenSize().width - 26.7, height: 1)
}
.frame(width: screenSize().width - 26.7, height: 130, alignment: .center)
}
}

View File

@ -0,0 +1,125 @@
//
// LiveReservationAllView.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import SwiftUI
struct LiveReservationAllView: View {
@ObservedObject var viewModel = LiveViewModel()
@StateObject var liveAllViewModel = LiveAllViewModel()
let onClickReservation: (Int) -> Void
let onClickStart: (Int) -> Void
let onClickCancel: () -> Void
let onTapCreateLive: () -> Void
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
GeometryReader { proxy in
VStack(spacing: 0) {
DetailNavigationBar(title: "라이브, 예약 캘린더")
WeekCalendarView { date in
viewModel.selectedDateString = date
}
.padding(.top, 20)
if viewModel.liveReservationItems.count > 0 {
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 13.3) {
ForEach(0..<viewModel.liveReservationItems.count, id: \.self) { index in
let item = viewModel.liveReservationItems[index]
LiveReservationAllItemView(item: item)
.contentShape(Rectangle())
.onTapGesture {
self.liveAllViewModel.selectedRoomId = item.roomId
withAnimation {
self.liveAllViewModel.isShowLiveDetail = true
}
}
.onAppear {
if index == viewModel.liveNowItems.count - 1 {
viewModel.getLiveReservationList()
}
}
}
}
}
.padding(.vertical, 16.7)
} else {
VStack(spacing: 0) {
Image("ic_no_item")
.resizable()
.frame(width: 60, height: 60)
Text("지금 예약중인 라이브가 없습니다.\n직접 라이브를 만들어 보세요!")
.font(.custom(Font.medium.rawValue, size: 10.7))
.foregroundColor(Color(hex: "bbbbbb"))
.multilineTextAlignment(.center)
.padding(.top, 8)
HStack(spacing: 0) {
Image("ic_plus_no_bg")
.resizable()
.frame(width: 33.3, height: 33.3, alignment: .center)
Text("라이브 만들기")
.font(.custom(Font.bold.rawValue, size: 13.3))
.foregroundColor(Color.white)
}
.frame(width: 200, height: 33.3, alignment: .center)
.background(Color(hex: "9970ff"))
.cornerRadius(4.7)
.padding(.top, 10.7)
.onTapGesture {
AppState.shared.back()
onTapCreateLive()
}
}
.padding(.vertical, 16.7)
.frame(width: screenSize().width - 26.7)
.background(Color(hex: "2b2635"))
.cornerRadius(4.7)
.padding(.top, 28.3)
}
if proxy.safeAreaInsets.bottom > 0 {
Rectangle()
.foregroundColor(Color.black.opacity(0))
.frame(width: screenSize().width, height: 15.3)
}
}
.edgesIgnoringSafeArea(.bottom)
}
if liveAllViewModel.isShowLiveDetail {
LiveDetailView(
roomId: liveAllViewModel.selectedRoomId,
onClickParticipant: {},
onClickReservation: {
onClickReservation(liveAllViewModel.selectedRoomId)
},
onClickStart: {
onClickStart(liveAllViewModel.selectedRoomId)
},
onClickCancel: {
viewModel.page = 1
viewModel.isLast = false
viewModel.getLiveReservationList()
onClickCancel()
},
onClickClose: {
withAnimation {
liveAllViewModel.isShowLiveDetail = false
}
}
)
}
}
}
}

View File

@ -0,0 +1,95 @@
//
// WeekCalendarView.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import SwiftUI
struct WeekCalendarView: View {
@State private var selectedIndex = 0
let action: (String) -> Void
@ViewBuilder
func DateItemView(index: Int, dateWithWeekDaySymbol: DateWithWeekDaySymbol) -> some View {
VStack(spacing: 6.7) {
Text(dateWithWeekDaySymbol.weekDaySymbol)
.font(.custom(Font.medium.rawValue, size: 11.3))
.foregroundColor(
self.selectedIndex == index ?
.white :
Color(hex: "e2e2e2")
)
Text(dateWithWeekDaySymbol.dayOfMonth)
.font(.custom(Font.bold.rawValue, size: 15.3))
.foregroundColor(
self.selectedIndex == index ?
.white :
Color(hex: "e2e2e2")
)
}
.frame(width: 53.3)
.frame(minHeight: 66.7)
.background(index == selectedIndex ? Color(hex: "9970ff") : Color.clear)
.cornerRadius(6.7)
.onTapGesture {
if self.selectedIndex != index {
self.selectedIndex = index
action(dateWithWeekDaySymbol.date)
}
}
}
var body: some View {
VStack(spacing: 16.7) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6.7) {
ForEach(0..<7, id: \.self) { index in
let dateWithWeekDaySymbol = getDateFromCurrent(afterDay: index)
DateItemView(
index: index,
dateWithWeekDaySymbol: dateWithWeekDaySymbol
)
}
}
}
.scaledToFit()
Rectangle()
.frame(height: 1)
.foregroundColor(Color(hex: "909090").opacity(0.5))
}
.onAppear {
self.selectedIndex = 0
action(getDateFromCurrent(afterDay: 0).date)
}
}
private func getDateFromCurrent(afterDay: Int) -> DateWithWeekDaySymbol {
var calendar = Calendar.current
calendar.locale = Locale(identifier: String(Locale.preferredLanguages[0].prefix(2)))
let currentDate = Date()
let futureDate = calendar.date(byAdding: .day, value: afterDay, to: currentDate)!
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let dayOfMonthFormatter = DateFormatter()
dayOfMonthFormatter.dateFormat = "d"
let date = dateFormatter.string(from: futureDate)
let dayOfMonth = dayOfMonthFormatter.string(from: futureDate)
let day = calendar.component(.weekday, from: futureDate) - 1
let weekDaySymbol = calendar.shortWeekdaySymbols[day]
return DateWithWeekDaySymbol(
date: date,
dayOfMonth: dayOfMonth,
weekDaySymbol: weekDaySymbol
)
}
}

View File

@ -9,7 +9,7 @@ import Foundation
struct MakeLiveReservationRequest: Encodable {
let roomId: Int
let password: Int?
let password: String?
let container: String = "ios"
let timezone: String = TimeZone.current.identifier
}

View File

@ -33,7 +33,16 @@ struct SectionLiveReservationView: View {
Text("전체보기")
.font(.custom(Font.light.rawValue, size: 11.3))
.foregroundColor(Color(hex: "bbbbbb"))
.onTapGesture {}
.onTapGesture {
AppState.shared.setAppStep(
step: .liveReservationAll(
onClickReservation: onClickReservation,
onClickStart: onClickStart,
onClickCancel: onClickCancel,
onTapCreateLive: onTapCreateLive
)
)
}
}
}
.padding(.horizontal, 13.3)
@ -47,17 +56,37 @@ struct SectionLiveReservationView: View {
if item.managerId == UserDefaults.int(forKey: .userId) {
MyLiveReservationItemView(item: item, index: index)
.contentShape(Rectangle())
.onTapGesture {}
.onTapGesture {
AppState.shared.setAppStep(
step: .liveDetail(
roomId: item.roomId,
onClickParticipant: {},
onClickReservation: {},
onClickStart: { onClickStart(item.roomId) },
onClickCancel: onClickCancel
)
)
}
} else {
LiveReservationItemView(item: item)
.contentShape(Rectangle())
.onTapGesture {}
.onTapGesture {
AppState.shared.setAppStep(
step: .liveDetail(
roomId: item.roomId,
onClickParticipant: {},
onClickReservation: {},
onClickStart: { onClickStart(item.roomId) },
onClickCancel: onClickCancel
)
)
}
}
}
}
.padding(.horizontal, 13.3)
.frame(width: screenSize().width)
.padding(.top, 28.3)
.padding(.top, 13.3)
} else {
VStack(spacing: 0) {
Image("ic_no_item")

View File

@ -11,15 +11,13 @@ struct GetRoomDetailResponse: Decodable {
let roomId: Int
let price: Int
let title: String
let content: String
let notice: String
let isPaid: Bool
let isPrivateRoom: Bool
let isSecretRoom: Bool
let password: Int?
let password: String?
let tags: [String]
let channelName: String?
let beginDateTime: String
let isNotification: Bool
let numberOfParticipants: Int
let numberOfParticipantsTotal: Int
let manager: GetRoomDetailManager
@ -35,7 +33,7 @@ struct GetRoomDetailManager: Decodable {
let websiteUrl: String?
let blogUrl: String?
let profileImageUrl: String
let isCounselor: Bool
let isCreator: Bool
}
struct GetRoomDetailUser: Decodable, Hashable {

View File

@ -0,0 +1,510 @@
//
// LiveDetailView.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import SwiftUI
import Kingfisher
struct LiveDetailView: View {
@ObservedObject var viewModel = LiveDetailViewModel()
@State private var isExpandParticipantArea = false
@State private var isShowCancelPopup = false
@StateObject var keyboardHandler = KeyboardHandler()
let columns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
let roomId: Int
let onClickParticipant: () -> Void
let onClickReservation: () -> Void
let onClickStart: () -> Void
let onClickCancel: () -> Void
var onClickClose: (() -> Void)? = nil
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
Color.black.opacity(0.7)
.onTapGesture {
viewModel.onBack {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
hideView()
}
}
}
if isShowCancelPopup {
LiveCancelDialog(isShowCancelPopup: $isShowCancelPopup) { reason in
viewModel.liveCancel(roomId: roomId, reason: reason) {
viewModel.errorMessage = "예약이 취소되었습니다."
viewModel.isShowPopup = true
onClickCancel()
}
}
} else {
GeometryReader { proxy in
VStack {
Spacer()
VStack(spacing: 0) {
HStack {
Spacer()
Image("ic_close_white")
.resizable()
.frame(width: 20, height: 20)
.padding(.top, 13.3)
.padding(.trailing, 13.3)
.onTapGesture {
viewModel.onBack {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
hideView()
}
}
}
}
if let room = viewModel.room {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
Text(room.title)
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color(hex: "eeeeee"))
.frame(width: proxy.size.width - 26.7, alignment: .leading)
.padding(.top, 6.7)
HStack(spacing: 0) {
Text(room.beginDateTime)
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color(hex: "bbbbbb"))
Spacer()
if room.price > 0 {
Text("\(room.price)")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color(hex: "eeeeee"))
Image("ic_can")
.resizable()
.frame(width: 26.7, height: 26.7)
.padding(.leading, 6.7)
} else {
Text("무료")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color(hex: "eeeeee"))
}
}
.padding(.top, 16.7)
.frame(width: proxy.size.width - 26.7)
Rectangle()
.frame(height: 1)
.foregroundColor(Color(hex: "909090").opacity(0.5))
.padding(.top, 8)
.frame(width: proxy.size.width - 26.7)
ParticipantView(room: room)
.frame(width: proxy.size.width - 26.7)
Rectangle()
.frame(height: 1)
.foregroundColor(Color(hex: "909090").opacity(0.5))
.padding(.top, 13.3)
HStack(spacing: 13.3) {
let manager = room.manager
KFImage(URL(string: manager.profileImageUrl))
.resizable()
.scaledToFill()
.frame(width: 60, height: 60, alignment: .top)
.background(Color(hex: "3e3658"))
.clipShape(Circle())
VStack(spacing: 16.7) {
HStack(spacing: 6.7) {
Text(manager.nickname)
.font(.custom(Font.medium.rawValue, size: 16.7))
.foregroundColor(Color(hex: "eeeeee"))
Spacer()
if let websiteUrl = manager.websiteUrl, let url = URL(string: websiteUrl), UIApplication.shared.canOpenURL(url) {
Image("ic_website_purple")
.resizable()
.frame(width: 33.3, height: 33.3)
.onTapGesture {
UIApplication.shared.open(url)
}
}
if let blogUrl = manager.blogUrl, let url = URL(string: blogUrl), UIApplication.shared.canOpenURL(url) {
Image("ic_blog_purple")
.resizable()
.frame(width: 33.3, height: 33.3)
.onTapGesture {
UIApplication.shared.open(url)
}
}
if let instagramUrl = manager.instagramUrl, let url = URL(string: instagramUrl), UIApplication.shared.canOpenURL(url) {
Image("ic_instagram_purple")
.resizable()
.frame(width: 33.3, height: 33.3)
.onTapGesture {
UIApplication.shared.open(url)
}
}
if let youtubeUrl = manager.youtubeUrl, let url = URL(string: youtubeUrl), UIApplication.shared.canOpenURL(url) {
Image("ic_youtube_play_purple")
.resizable()
.frame(width: 33.3, height: 33.3)
.onTapGesture {
UIApplication.shared.open(url)
}
}
}
HStack(alignment: .center, spacing: 0) {
Text(manager.introduce)
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color(hex: "777777"))
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
Spacer()
if manager.isCreator {
HStack(spacing: 3.3) {
Image("ic_thumb_play")
.resizable()
.frame(width: 13.3, height: 13.3)
Text("채널보기")
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color.white)
.onTapGesture {
AppState.shared.setAppStep(step: .creatorDetail(userId: manager.id))
}
}
.padding(.horizontal, 8.7)
.padding(.vertical, 10)
.background(Color(hex: "9970ff"))
.cornerRadius(16.7)
.onTapGesture {
}
}
}
}
}
.padding(.vertical, 20)
.padding(.horizontal, 13.3)
.frame(width: proxy.size.width)
.background(Color(hex: "111111"))
Rectangle()
.frame(height: 1)
.foregroundColor(Color(hex: "909090").opacity(0.5))
Text(room.tags.map { "#\($0)" }.joined(separator: " "))
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(Color(hex: "9970ff"))
.frame(width: proxy.size.width - 26, alignment: .leading)
.padding(.top, 26.7)
Text(room.notice)
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(Color(hex: "777777"))
.frame(width: proxy.size.width - 26, alignment: .leading)
.padding(.top, 26.7)
Rectangle()
.frame(width: proxy.size.width - 26.7, height: 1)
.foregroundColor(Color(hex: "909090").opacity(0.5))
.padding(.vertical, 40)
}
}
}
if !viewModel.isShowPopup {
JoinButton()
.padding(.bottom, 26.7)
}
if proxy.safeAreaInsets.bottom > 0 {
Rectangle()
.foregroundColor(Color(hex: "222222"))
.frame(width: screenSize().width, height: 15.3)
}
}
.frame(width: proxy.size.width, height: proxy.size.height * 0.9)
.background(Color(hex: "222222"))
.cornerRadius(16.7)
.offset(y: viewModel.showDetail ? 0 : proxy.size.height * 0.9)
.animation(.easeInOut(duration: 0.25), value: viewModel.showDetail)
}
.edgesIgnoringSafeArea(.bottom)
}
}
if viewModel.isLoading {
LoadingView()
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 1) {
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()
}
.onDisappear {
hideView()
}
}
}
.sheet(
isPresented: $viewModel.isShowShareView,
onDismiss: { viewModel.shareMessage = "" },
content: {
ActivityViewController(activityItems: [viewModel.shareMessage])
}
)
.onAppear {
viewModel.getDetail(roomId: roomId)
}
}
@ViewBuilder
private func JoinButton() -> some View {
if let room = viewModel.room {
HStack {
if room.channelName.isNullOrBlank() {
if room.manager.id == UserDefaults.int(forKey: .userId) {
VStack(spacing: 16.7) {
HStack(spacing: 13.3) {
Image("btn_big_share")
.onTapGesture {
viewModel.shareRoom(roomId: room.roomId)
}
Text("수정")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color.white)
.padding(.vertical, 16)
.padding(.horizontal, 27)
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(lineWidth: 1)
.foregroundColor(Color(hex: "9970ff"))
)
.onTapGesture {
AppState.shared.back()
AppState.shared.setAppStep(step: .modifyLive(room: room))
}
Text("라이브 시작")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color.white)
.padding(.vertical, 16)
.frame(maxWidth: .infinity)
.background(Color(hex: "9970ff"))
.cornerRadius(10)
.onTapGesture {
onClickStart()
AppState.shared.back()
}
}
Text("예약삭제")
.font(.custom(Font.medium.rawValue, size: 14))
.foregroundColor(Color(hex: "ff5c49"))
.padding(5.3)
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(lineWidth: 1)
.foregroundColor(Color(hex: "dd4500"))
)
.onTapGesture {
isShowCancelPopup = true
}
}
.frame(width: screenSize().width - 26.7)
} else if room.isPaid {
HStack(spacing: 13.3) {
Button {
viewModel.shareRoom(roomId: room.roomId)
} label: {
Image("btn_big_share")
}
Text("예약완료")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color(hex: "777777"))
.padding(.vertical, 16)
.padding(.horizontal, 99)
.background(Color(hex: "525252"))
.cornerRadius(10)
}
.frame(width: screenSize().width - 26.7)
} else {
HStack(spacing: 13.3) {
Button {
viewModel.shareRoom(roomId: room.roomId)
} label: {
Image("btn_big_share")
}
Button {
onClickReservation()
AppState.shared.back()
} label: {
Text("예약하기")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color.white)
.padding(.vertical, 16)
.padding(.horizontal, 99)
.background(Color(hex: "9970ff"))
.cornerRadius(10)
}
}
.frame(width: screenSize().width - 26.7)
}
} else {
HStack(spacing: 13.3) {
Button {
viewModel.shareRoom(roomId: room.roomId)
} label: {
Image("btn_big_share")
}
Button {
onClickParticipant()
AppState.shared.back()
} label: {
Text("지금 참여하기")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color.white)
.padding(.vertical, 16)
.padding(.horizontal, 79)
.background(Color(hex: "ff5c49"))
.cornerRadius(10)
}
}
.frame(width: screenSize().width - 26.7)
}
}
}
}
@ViewBuilder
private func ParticipantView(room: GetRoomDetailResponse) -> some View {
if isExpandParticipantArea {
HStack(spacing: 0) {
Text(room.channelName.isNullOrBlank() ? "예약자" : "참가자")
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color(hex: "eeeeee"))
Spacer()
Text("\(room.numberOfParticipants)")
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color(hex: "9970ff"))
Text("/\(room.numberOfParticipantsTotal)")
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color(hex: "bbbbbb"))
}
.padding(.top, 16.7)
LazyVGrid(columns: columns) {
ForEach(room.participatingUsers, id: \.self) { user in
VStack(spacing: 6.7) {
KFImage(URL(string: user.profileImageUrl))
.resizable()
.scaledToFill()
.frame(width: 46.7, height: 46.7, alignment: .top)
.clipShape(Circle())
Text(user.nickname)
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color(hex: "bbbbbb"))
.lineLimit(1)
}
}
}
.padding(.top, 16.7)
} else {
let userCount = room.numberOfParticipants > 10 ? 10 : room.numberOfParticipants
HStack(spacing: -13.3) {
ForEach(0..<userCount, id: \.self) { index in
let user = room.participatingUsers[index]
KFImage(URL(string: user.profileImageUrl))
.resizable()
.scaledToFill()
.frame(width: 33.3, height: 33.3, alignment: .top)
.clipShape(Circle())
}
Spacer()
HStack(spacing: 0) {
Text("\(room.numberOfParticipants)")
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color(hex: "9970ff"))
Text("/\(room.numberOfParticipantsTotal)")
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color(hex: "bbbbbb"))
}
}
.padding(.top, 22)
}
if room.numberOfParticipants > 0 {
HStack(spacing: 6.7) {
Image(isExpandParticipantArea ? "ic_suda_detail_top" : "ic_suda_detail_bottom")
.resizable()
.frame(width: 20, height: 20)
Text(isExpandParticipantArea ? "닫기" : "펼쳐보기")
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color(hex: "bbbbbb"))
}
.padding(.top, 13.3)
.onTapGesture {
isExpandParticipantArea.toggle()
}
}
}
private func hideView() {
if let close = onClickClose {
close()
} else {
AppState.shared.back()
}
}
}

View File

@ -0,0 +1,162 @@
//
// LiveDetailViewModel.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import Foundation
import Combine
import FirebaseDynamicLinks
final class LiveDetailViewModel: ObservableObject {
private let repository = LiveRepository()
private var subscription = Set<AnyCancellable>()
@Published var isLoading = false
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var room: GetRoomDetailResponse? = nil
@Published var showDetail = false
@Published var shareMessage = ""
@Published var isShowShareView = false
func getDetail(roomId: Int) {
if !isLoading {
isLoading = true
repository.getRoomDetail(roomId: roomId)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { response in
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<GetRoomDetailResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
DispatchQueue.main.async {
self.showDetail = true
self.room = data
}
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self.isShowPopup = true
}
} catch {
print(error)
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
}
self.isLoading = false
}
.store(in: &subscription)
}
}
func onBack(afterExecute: () -> Void) {
showDetail = false
afterExecute()
}
func liveCancel(roomId: Int, reason: String, onSuccess: @escaping () -> Void) {
isLoading = true
repository.cancelRoom(roomId: roomId, reason: reason)
.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(ApiResponseWithoutData.self, from: responseData)
if decoded.success {
onSuccess()
} 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 shareRoom(roomId: Int) {
isLoading = true
guard let link = URL(string: "https://sodalive.net/?room_id=\(roomId)") else { return }
let dynamicLinksDomainURIPrefix = "https://sodalive.page.link"
guard let linkBuilder = DynamicLinkComponents(link: link, domainURIPrefix: dynamicLinksDomainURIPrefix) else {
self.errorMessage = "공유링크를 생성하지 못했습니다.\n다시 시도해 주세요."
self.isShowPopup = true
isLoading = false
return
}
linkBuilder.iOSParameters = DynamicLinkIOSParameters(bundleID: "kr.co.vividnext.sodalive")
linkBuilder.iOSParameters?.appStoreID = "1630284226"
linkBuilder.androidParameters = DynamicLinkAndroidParameters(packageName: "kr.co.vividnext.sodalive")
guard let longDynamicLink = linkBuilder.url else {
self.errorMessage = "공유링크를 생성하지 못했습니다.\n다시 시도해 주세요."
self.isShowPopup = true
isLoading = false
return
}
DEBUG_LOG("The long URL is: \(longDynamicLink)")
DynamicLinkComponents.shortenURL(longDynamicLink, options: nil) { [unowned self] url, warnings, error in
let shortUrl = url?.absoluteString
if let liveRoomInfo = self.room {
let urlString = shortUrl != nil ? shortUrl! : longDynamicLink.absoluteString
if liveRoomInfo.isPrivateRoom {
shareMessage = "\(UserDefaults.string(forKey: .nickname))님이 귀하를 소다라이브 비공개라이브에 초대하였습니다.\n" +
"※ 라이브 참여: \(urlString)\n" +
"(입장 비밀번호: \(liveRoomInfo.password!))"
} else {
shareMessage = "\(UserDefaults.string(forKey: .nickname))님이 귀하를 소다라이브 공개라이브에 초대하였습니다.\n" +
"※ 라이브 참여: \(urlString)"
}
isShowShareView = true
} else {
self.errorMessage = "공유링크를 생성하지 못했습니다.\n다시 시도해 주세요."
self.isShowPopup = true
return
}
isLoading = false
}
}
}

View File

@ -0,0 +1,16 @@
//
// EditLiveRoomInfoRequest.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import Foundation
struct EditLiveRoomInfoRequest: Encodable {
let title: String?
let notice: String?
let numberOfPeople: Int?
let beginDateTimeString: String?
let timezone: String?
}

View File

@ -0,0 +1,292 @@
//
// LiveRoomEditView.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import SwiftUI
struct LiveRoomEditView: View {
@StateObject var keyboardHandler = KeyboardHandler()
@StateObject var viewModel = LiveRoomEditViewModel()
@State private var isShowSelectDateView = false
@State private var isShowSelectTimeView = false
let room: GetRoomDetailResponse
init(room: GetRoomDetailResponse) {
UITextView.appearance().backgroundColor = .clear
UIScrollView.appearance().bounces = false
self.room = room
}
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
ZStack {
VStack(spacing: 0) {
DetailNavigationBar(title: "라이브 수정")
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
TitleInputView()
.frame(width: screenSize().width - 26.7)
.padding(.top, 33.3)
ContentInputView()
.frame(width: screenSize().width - 26.7)
.padding(.top, 33.3)
ReservationDateTimeView(buttonWidth: (screenSize().width - 40) / 2)
.padding(.top, 22.7)
NumberOfPeopleLimitView()
.frame(width: screenSize().width - 26.7)
.padding(.top, 33.3)
if !viewModel.isLoading {
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(.top, 30)
.onTapGesture {
viewModel.updateLiveRoom()
}
}
Rectangle()
.foregroundColor(Color(hex: "222222"))
.frame(width: screenSize().width, height: keyboardHandler.keyboardHeight)
}
}
}
if isShowSelectDateView {
SelectDateView()
}
if isShowSelectTimeView {
SelectTimeView()
}
}
.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.room = room
}
}
@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 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.notice.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.notice,
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 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)
}
}
}

View File

@ -0,0 +1,178 @@
//
// LiveRoomEditViewModel.swift
// SodaLive
//
// Created by klaus on 2023/08/14.
//
import Foundation
import Moya
import Combine
final class LiveRoomEditViewModel: ObservableObject {
@Published var isLoading = false
@Published var title: String = ""
@Published var notice: String = "" {
didSet {
if notice.count > 1000 {
notice = String(notice.prefix(1000))
}
}
}
@Published var numberOfPeople = ""
@Published var reservationDateString: String = ""
@Published var reservationTimeString: String = ""
@Published var errorMessage = ""
@Published var isShowPopup = false
private let repository = LiveRepository()
private var subscription = Set<AnyCancellable>()
var reservationDate = Date() {
didSet {
reservationDateString = reservationDate.convertDateFormat(dateFormat: "yyyy.MM.dd")
}
}
var reservationTime = Date() {
didSet {
reservationTimeString = reservationTime.convertDateFormat(dateFormat: "a hh:mm")
}
}
let placeholder = "라이브 공지를 입력하세요"
var room: GetRoomDetailResponse? = nil {
didSet {
isLoading = true
title = room!.title
notice = room!.notice
numberOfPeople = String(room!.numberOfParticipantsTotal)
let fromFormatter = DateFormatter()
fromFormatter.dateFormat = "yyyy.MM.dd EEE hh:mm a"
fromFormatter.locale = Locale(identifier: "en_US_POSIX")
reservationDate = fromFormatter.date(from: room!.beginDateTime)!
reservationTime = fromFormatter.date(from: room!.beginDateTime)!
let beginDate = reservationDate.convertDateFormat(dateFormat: "yyyy-MM-dd")
let beginTime = reservationTime.convertDateFormat(dateFormat: "HH:mm")
beginDateTimeStr = "\(beginDate) \(beginTime)"
isLoading = false
}
}
var beginDateTimeStr: String = ""
func updateLiveRoom() {
if let room = room, !isLoading && validate() {
isLoading = true
let beginDate = reservationDate.convertDateFormat(dateFormat: "yyyy-MM-dd")
let beginTime = reservationTime.convertDateFormat(dateFormat: "HH:mm")
let beginDateTime = "\(beginDate) \(beginTime)"
let request = EditLiveRoomInfoRequest(
title: room.title != title ? title : nil,
notice: room.notice != notice ? notice : nil,
numberOfPeople: room.numberOfParticipantsTotal != Int(numberOfPeople)! ? Int(numberOfPeople)! : nil,
beginDateTimeString: beginDateTimeStr != beginDateTime ? beginDateTime : nil,
timezone: TimeZone.current.identifier
)
if (
request.title == nil &&
request.notice == nil &&
request.numberOfPeople == nil &&
request.beginDateTimeString == nil
) {
self.errorMessage = "변경사항이 없습니다."
self.isShowPopup = true
isLoading = false
return
}
var multipartData = [MultipartFormData]()
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes
let jsonData = try? encoder.encode(request)
if let jsonData = jsonData {
multipartData.append(MultipartFormData(provider: .data(jsonData), name: "request"))
repository.editLiveRoomInfo(roomId: room.roomId, 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(ApiResponseWithoutData.self, from: responseData)
if decoded.success {
self.errorMessage = "라이브 정보가 수정되었습니다."
self.isShowPopup = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
AppState.shared.back()
}
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "라이브 정보를 수정 하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "라이브 정보를 수정 하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
}
}
.store(in: &subscription)
} else {
self.errorMessage = "라이브 정보를 수정 하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
self.isLoading = false
}
}
}
private func validate() -> Bool {
if title.trimmingCharacters(in: .whitespaces).isEmpty {
self.errorMessage = "제목을 입력해 주세요."
self.isShowPopup = true
return false
}
let notice = notice.trimmingCharacters(in: .whitespacesAndNewlines) != placeholder ? notice : ""
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
}
return true
}
}

View File

@ -10,5 +10,5 @@ import Foundation
struct EnterOrQuitLiveRoomRequest: Encodable {
let roomId: Int
let container: String = "ios"
var password: Int? = nil
var password: String? = nil
}

View File

@ -0,0 +1,259 @@
// Generated using the ObjectBox Swift Generator https://objectbox.io
// DO NOT EDIT
// swiftlint:disable all
import ObjectBox
import Foundation
// MARK: - Entity metadata
extension PlaybackTracking: ObjectBox.__EntityRelatable {
internal typealias EntityType = PlaybackTracking
internal var _id: EntityId<PlaybackTracking> {
return EntityId<PlaybackTracking>(self.id.value)
}
}
extension PlaybackTracking: ObjectBox.EntityInspectable {
internal typealias EntityBindingType = PlaybackTrackingBinding
/// Generated metadata used by ObjectBox to persist the entity.
internal static var entityInfo = ObjectBox.EntityInfo(name: "PlaybackTracking", id: 1)
internal static var entityBinding = EntityBindingType()
fileprivate static func buildEntity(modelBuilder: ObjectBox.ModelBuilder) throws {
let entityBuilder = try modelBuilder.entityBuilder(for: PlaybackTracking.self, id: 1, uid: 1902306876074642688)
try entityBuilder.addProperty(name: "id", type: PropertyType.long, flags: [.id], id: 1, uid: 3822545071117514752)
try entityBuilder.addProperty(name: "audioContentId", type: PropertyType.long, id: 2, uid: 6201823391120048640)
try entityBuilder.addProperty(name: "totalDuration", type: PropertyType.long, id: 3, uid: 8353299921632812032)
try entityBuilder.addProperty(name: "startPosition", type: PropertyType.long, id: 4, uid: 3188699482915899648)
try entityBuilder.addProperty(name: "isFree", type: PropertyType.bool, id: 5, uid: 2487054984108217856)
try entityBuilder.addProperty(name: "isPreview", type: PropertyType.bool, id: 6, uid: 5106135603734636032)
try entityBuilder.addProperty(name: "endPosition", type: PropertyType.long, id: 7, uid: 8116657363890041600)
try entityBuilder.addProperty(name: "playDateTime", type: PropertyType.string, id: 8, uid: 8837430652093702400)
try entityBuilder.lastProperty(id: 8, uid: 8837430652093702400)
}
}
extension PlaybackTracking {
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { PlaybackTracking.id == myId }
internal static var id: Property<PlaybackTracking, Id, Id> { return Property<PlaybackTracking, Id, Id>(propertyId: 1, isPrimaryKey: true) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { PlaybackTracking.audioContentId > 1234 }
internal static var audioContentId: Property<PlaybackTracking, Int, Void> { return Property<PlaybackTracking, Int, Void>(propertyId: 2, isPrimaryKey: false) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { PlaybackTracking.totalDuration > 1234 }
internal static var totalDuration: Property<PlaybackTracking, Int, Void> { return Property<PlaybackTracking, Int, Void>(propertyId: 3, isPrimaryKey: false) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { PlaybackTracking.startPosition > 1234 }
internal static var startPosition: Property<PlaybackTracking, Int, Void> { return Property<PlaybackTracking, Int, Void>(propertyId: 4, isPrimaryKey: false) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { PlaybackTracking.isFree == true }
internal static var isFree: Property<PlaybackTracking, Bool, Void> { return Property<PlaybackTracking, Bool, Void>(propertyId: 5, isPrimaryKey: false) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { PlaybackTracking.isPreview == true }
internal static var isPreview: Property<PlaybackTracking, Bool, Void> { return Property<PlaybackTracking, Bool, Void>(propertyId: 6, isPrimaryKey: false) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { PlaybackTracking.endPosition > 1234 }
internal static var endPosition: Property<PlaybackTracking, Int?, Void> { return Property<PlaybackTracking, Int?, Void>(propertyId: 7, isPrimaryKey: false) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { PlaybackTracking.playDateTime.startsWith("X") }
internal static var playDateTime: Property<PlaybackTracking, String, Void> { return Property<PlaybackTracking, String, Void>(propertyId: 8, isPrimaryKey: false) }
fileprivate func __setId(identifier: ObjectBox.Id) {
self.id = Id(identifier)
}
}
extension ObjectBox.Property where E == PlaybackTracking {
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { .id == myId }
internal static var id: Property<PlaybackTracking, Id, Id> { return Property<PlaybackTracking, Id, Id>(propertyId: 1, isPrimaryKey: true) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { .audioContentId > 1234 }
internal static var audioContentId: Property<PlaybackTracking, Int, Void> { return Property<PlaybackTracking, Int, Void>(propertyId: 2, isPrimaryKey: false) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { .totalDuration > 1234 }
internal static var totalDuration: Property<PlaybackTracking, Int, Void> { return Property<PlaybackTracking, Int, Void>(propertyId: 3, isPrimaryKey: false) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { .startPosition > 1234 }
internal static var startPosition: Property<PlaybackTracking, Int, Void> { return Property<PlaybackTracking, Int, Void>(propertyId: 4, isPrimaryKey: false) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { .isFree == true }
internal static var isFree: Property<PlaybackTracking, Bool, Void> { return Property<PlaybackTracking, Bool, Void>(propertyId: 5, isPrimaryKey: false) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { .isPreview == true }
internal static var isPreview: Property<PlaybackTracking, Bool, Void> { return Property<PlaybackTracking, Bool, Void>(propertyId: 6, isPrimaryKey: false) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { .endPosition > 1234 }
internal static var endPosition: Property<PlaybackTracking, Int?, Void> { return Property<PlaybackTracking, Int?, Void>(propertyId: 7, isPrimaryKey: false) }
/// Generated entity property information.
///
/// You may want to use this in queries to specify fetch conditions, for example:
///
/// box.query { .playDateTime.startsWith("X") }
internal static var playDateTime: Property<PlaybackTracking, String, Void> { return Property<PlaybackTracking, String, Void>(propertyId: 8, isPrimaryKey: false) }
}
/// Generated service type to handle persisting and reading entity data. Exposed through `PlaybackTracking.EntityBindingType`.
internal class PlaybackTrackingBinding: ObjectBox.EntityBinding {
internal typealias EntityType = PlaybackTracking
internal typealias IdType = Id
internal required init() {}
internal func generatorBindingVersion() -> Int { 1 }
internal func setEntityIdUnlessStruct(of entity: EntityType, to entityId: ObjectBox.Id) {
entity.__setId(identifier: entityId)
}
internal func entityId(of entity: EntityType) -> ObjectBox.Id {
return entity.id.value
}
internal func collect(fromEntity entity: EntityType, id: ObjectBox.Id,
propertyCollector: ObjectBox.FlatBufferBuilder, store: ObjectBox.Store) throws {
let propertyOffset_playDateTime = propertyCollector.prepare(string: entity.playDateTime)
propertyCollector.collect(id, at: 2 + 2 * 1)
propertyCollector.collect(entity.audioContentId, at: 2 + 2 * 2)
propertyCollector.collect(entity.totalDuration, at: 2 + 2 * 3)
propertyCollector.collect(entity.startPosition, at: 2 + 2 * 4)
propertyCollector.collect(entity.isFree, at: 2 + 2 * 5)
propertyCollector.collect(entity.isPreview, at: 2 + 2 * 6)
propertyCollector.collect(entity.endPosition, at: 2 + 2 * 7)
propertyCollector.collect(dataOffset: propertyOffset_playDateTime, at: 2 + 2 * 8)
}
internal func createEntity(entityReader: ObjectBox.FlatBufferReader, store: ObjectBox.Store) -> EntityType {
let entity = PlaybackTracking()
entity.id = entityReader.read(at: 2 + 2 * 1)
entity.audioContentId = entityReader.read(at: 2 + 2 * 2)
entity.totalDuration = entityReader.read(at: 2 + 2 * 3)
entity.startPosition = entityReader.read(at: 2 + 2 * 4)
entity.isFree = entityReader.read(at: 2 + 2 * 5)
entity.isPreview = entityReader.read(at: 2 + 2 * 6)
entity.endPosition = entityReader.read(at: 2 + 2 * 7)
entity.playDateTime = entityReader.read(at: 2 + 2 * 8)
return entity
}
}
/// Helper function that allows calling Enum(rawValue: value) with a nil value, which will return nil.
fileprivate func optConstruct<T: RawRepresentable>(_ type: T.Type, rawValue: T.RawValue?) -> T? {
guard let rawValue = rawValue else { return nil }
return T(rawValue: rawValue)
}
// MARK: - Store setup
fileprivate func cModel() throws -> OpaquePointer {
let modelBuilder = try ObjectBox.ModelBuilder()
try PlaybackTracking.buildEntity(modelBuilder: modelBuilder)
modelBuilder.lastEntity(id: 1, uid: 1902306876074642688)
return modelBuilder.finish()
}
extension ObjectBox.Store {
/// A store with a fully configured model. Created by the code generator with your model's metadata in place.
///
/// - Parameters:
/// - directoryPath: The directory path in which ObjectBox places its database files for this store.
/// - maxDbSizeInKByte: Limit of on-disk space for the database files. Default is `1024 * 1024` (1 GiB).
/// - fileMode: UNIX-style bit mask used for the database files; default is `0o644`.
/// Note: directories become searchable if the "read" or "write" permission is set (e.g. 0640 becomes 0750).
/// - maxReaders: The maximum number of readers.
/// "Readers" are a finite resource for which we need to define a maximum number upfront.
/// The default value is enough for most apps and usually you can ignore it completely.
/// However, if you get the maxReadersExceeded error, you should verify your
/// threading. For each thread, ObjectBox uses multiple readers. Their number (per thread) depends
/// on number of types, relations, and usage patterns. Thus, if you are working with many threads
/// (e.g. in a server-like scenario), it can make sense to increase the maximum number of readers.
/// Note: The internal default is currently around 120.
/// So when hitting this limit, try values around 200-500.
/// - important: This initializer is created by the code generator. If you only see the internal `init(model:...)`
/// initializer, trigger code generation by building your project.
internal convenience init(directoryPath: String, maxDbSizeInKByte: UInt64 = 1024 * 1024,
fileMode: UInt32 = 0o644, maxReaders: UInt32 = 0, readOnly: Bool = false) throws {
try self.init(
model: try cModel(),
directory: directoryPath,
maxDbSizeInKByte: maxDbSizeInKByte,
fileMode: fileMode,
maxReaders: maxReaders,
readOnly: readOnly)
}
}
// swiftlint:enable all

View File

@ -0,0 +1,57 @@
// Build your project to run Sourcery and create current contents for this file
// Generated using the ObjectBox Swift Generator https://objectbox.io
// DO NOT EDIT
// swiftlint:disable all
import ObjectBox
import Foundation
// MARK: - Entity metadata
/// Helper function that allows calling Enum(rawValue: value) with a nil value, which will return nil.
fileprivate func optConstruct<T: RawRepresentable>(_ type: T.Type, rawValue: T.RawValue?) -> T? {
guard let rawValue = rawValue else { return nil }
return T(rawValue: rawValue)
}
// MARK: - Store setup
fileprivate func cModel() throws -> OpaquePointer {
let modelBuilder = try ObjectBox.ModelBuilder()
modelBuilder.lastEntity(id: 0, uid: 0)
return modelBuilder.finish()
}
extension ObjectBox.Store {
/// A store with a fully configured model. Created by the code generator with your model's metadata in place.
///
/// - Parameters:
/// - directoryPath: The directory path in which ObjectBox places its database files for this store.
/// - maxDbSizeInKByte: Limit of on-disk space for the database files. Default is `1024 * 1024` (1 GiB).
/// - fileMode: UNIX-style bit mask used for the database files; default is `0o644`.
/// Note: directories become searchable if the "read" or "write" permission is set (e.g. 0640 becomes 0750).
/// - maxReaders: The maximum number of readers.
/// "Readers" are a finite resource for which we need to define a maximum number upfront.
/// The default value is enough for most apps and usually you can ignore it completely.
/// However, if you get the maxReadersExceeded error, you should verify your
/// threading. For each thread, ObjectBox uses multiple readers. Their number (per thread) depends
/// on number of types, relations, and usage patterns. Thus, if you are working with many threads
/// (e.g. in a server-like scenario), it can make sense to increase the maximum number of readers.
/// Note: The internal default is currently around 120.
/// So when hitting this limit, try values around 200-500.
/// - important: This initializer is created by the code generator. If you only see the internal `init(model:...)`
/// initializer, trigger code generation by building your project.
internal convenience init(directoryPath: String, maxDbSizeInKByte: UInt64 = 1024 * 1024,
fileMode: UInt32 = 0o644, maxReaders: UInt32 = 0, readOnly: Bool = false) throws {
try self.init(
model: try cModel(),
directory: directoryPath,
maxDbSizeInKByte: maxDbSizeInKByte,
fileMode: fileMode,
maxReaders: maxReaders,
readOnly: readOnly)
}
}
// swiftlint:enable all

67
model-SodaLive-dev.json Normal file
View File

@ -0,0 +1,67 @@
{
"_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.",
"_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.",
"_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.",
"entities": [
{
"id": "1:1902306876074642688",
"lastPropertyId": "8:8837430652093702400",
"name": "PlaybackTracking",
"properties": [
{
"flags": 1,
"id": "1:3822545071117514752",
"name": "id",
"type": 6
},
{
"id": "2:6201823391120048640",
"name": "audioContentId",
"type": 6
},
{
"id": "3:8353299921632812032",
"name": "totalDuration",
"type": 6
},
{
"id": "4:3188699482915899648",
"name": "startPosition",
"type": 6
},
{
"id": "5:2487054984108217856",
"name": "isFree",
"type": 1
},
{
"id": "6:5106135603734636032",
"name": "isPreview",
"type": 1
},
{
"id": "7:8116657363890041600",
"name": "endPosition",
"type": 6
},
{
"id": "8:8837430652093702400",
"name": "playDateTime",
"type": 9
}
],
"relations": []
}
],
"lastEntityId": "1:1902306876074642688",
"lastIndexId": "0:0",
"lastRelationId": "0:0",
"lastSequenceId": "0:0",
"modelVersion": 5,
"modelVersionParserMinimum": 4,
"retiredEntityUids": [],
"retiredIndexUids": [],
"retiredPropertyUids": [],
"retiredRelationUids": [],
"version": 1
}