diff --git a/SodaLive/Resources/Assets.xcassets/ic_thumb_play.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_thumb_play.imageset/Contents.json new file mode 100644 index 0000000..ab30ef1 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_thumb_play.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_thumb_play.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_thumb_play.imageset/ic_thumb_play.png b/SodaLive/Resources/Assets.xcassets/ic_thumb_play.imageset/ic_thumb_play.png new file mode 100644 index 0000000..99fc670 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_thumb_play.imageset/ic_thumb_play.png differ diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index a3547db..753c7cc 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -53,4 +53,6 @@ enum AppStep { case liveReservationCancel(reservationId: Int) case serviceCenter + + case createContent } diff --git a/SodaLive/Sources/Content/AddAllPlaybackTrackingRequest.swift b/SodaLive/Sources/Content/AddAllPlaybackTrackingRequest.swift new file mode 100644 index 0000000..8ea769d --- /dev/null +++ b/SodaLive/Sources/Content/AddAllPlaybackTrackingRequest.swift @@ -0,0 +1,19 @@ +// +// AddAllPlaybackTrackingRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct AddAllPlaybackTrackingRequest: Encodable { + let timezone = TimeZone.current.identifier + let trackingDataList: [PlaybackTrackingData] +} + +struct PlaybackTrackingData: Encodable { + let audioContentId: Int + let playDateTime: String + let isPreview: Bool +} diff --git a/SodaLive/Sources/Content/ContentApi.swift b/SodaLive/Sources/Content/ContentApi.swift new file mode 100644 index 0000000..4bb2686 --- /dev/null +++ b/SodaLive/Sources/Content/ContentApi.swift @@ -0,0 +1,182 @@ +// +// ContentApi.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation +import Moya + +enum ContentApi { + case getAudioContentList(userId: Int, page: Int, size: Int, sort: ContentListViewModel.Sort) + case getAudioContentDetail(audioContentId: Int) + case likeContent(request: PutAudioContentLikeRequest) + case registerComment(request: RegisterAudioContentCommentRequest) + case orderAudioContent(request: OrderRequest) + case getOrderList(page: Int, size: Int) + case addAllPlaybackTracking(request: AddAllPlaybackTrackingRequest) + case getAudioContentThemeList + case uploadAudioContent(parameters: [MultipartFormData]) + case getAudioContentCommentList(audioContentId: Int, page: Int, size: Int) + case getAudioContentCommentReplyList(commentId: Int, page: Int, size: Int) + case deleteAudioContent(audioContentId: Int) + case modifyAudioContent(parameters: [MultipartFormData]) + case getMain + case getNewContentOfTheme(theme: String) + case donation(request: AudioContentDonationRequest) +} + +extension ContentApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getAudioContentList: + return "/audio-content" + + case .getAudioContentDetail(let audioContentId): + return "/audio-content/\(audioContentId)" + + case .likeContent: + return "/audio-content/like" + + case .registerComment: + return "/audio-content/comment" + + case .orderAudioContent: + return "/order/audio-content" + + case .getOrderList: + return "/order/audio-content" + + case .addAllPlaybackTracking: + return "/audio-content/playback-tracking" + + case .getAudioContentThemeList: + return "/audio-content/theme" + + case .uploadAudioContent: + return "/audio-content" + + case .getAudioContentCommentList(let audioContentId, _, _): + return "/audio-content/\(audioContentId)/comment" + + case .getAudioContentCommentReplyList(let commentId, _, _): + return "/audio-content/comment/\(commentId)" + + case .deleteAudioContent(let audioContentId): + return "/audio-content/\(audioContentId)" + + case .modifyAudioContent: + return "/audio-content" + + case .getMain: + return "/audio-content/main" + + case .getNewContentOfTheme: + return "/audio-content/main/new" + + case .donation: + return "/audio-content/donation" + } + } + + var method: Moya.Method { + switch self { + case .getAudioContentList, .getAudioContentDetail, .getOrderList, .getAudioContentThemeList, .getAudioContentCommentList, .getAudioContentCommentReplyList, .getMain, .getNewContentOfTheme: + return .get + + case .likeContent, .modifyAudioContent: + return .put + + case .registerComment, .orderAudioContent, .addAllPlaybackTracking, .uploadAudioContent, .donation: + return .post + + case .deleteAudioContent: + return .delete + } + } + + var task: Moya.Task { + switch self { + case .getAudioContentList(let userId, let page, let size, let sort): + let parameters = [ + "creator-id": userId, + "page": page - 1, + "size": size, + "sort-type": sort + ] as [String : Any] + + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + + case .getAudioContentDetail: + let parameters = ["timezone": TimeZone.current.identifier] as [String : Any] + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + + case .likeContent(let request): + return .requestJSONEncodable(request) + + case .registerComment(let request): + return .requestJSONEncodable(request) + + case .orderAudioContent(let request): + return .requestJSONEncodable(request) + + case .getOrderList(let page, let size): + let parameters = [ + "page": page - 1, + "size": size + ] as [String : Any] + + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + + case .addAllPlaybackTracking(let request): + return .requestJSONEncodable(request) + + case .getAudioContentThemeList, .getMain: + return .requestPlain + + case .uploadAudioContent(let parameters): + return .uploadMultipart(parameters) + + case .modifyAudioContent(let parameters): + return .uploadMultipart(parameters) + + case .getAudioContentCommentList(_, let page, let size): + let parameters = [ + "page": page - 1, + "size": size, + "timezone": TimeZone.current.identifier + ] as [String : Any] + + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + + case .getAudioContentCommentReplyList(_, let page, let size): + let parameters = [ + "page": page - 1, + "size": size, + "timezone": TimeZone.current.identifier + ] as [String : Any] + + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + + case .deleteAudioContent: + return .requestPlain + + case .getNewContentOfTheme(let theme): + let parameters = ["theme": theme] as [String : Any] + + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + + case .donation(let request): + return .requestJSONEncodable(request) + } + } + + var headers: [String : String]? { + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} diff --git a/SodaLive/Sources/Content/ContentListViewModel.swift b/SodaLive/Sources/Content/ContentListViewModel.swift new file mode 100644 index 0000000..4f64869 --- /dev/null +++ b/SodaLive/Sources/Content/ContentListViewModel.swift @@ -0,0 +1,93 @@ +// +// ContentListViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation +import Combine + +final class ContentListViewModel: ObservableObject { + + enum Sort: String { + case NEWEST, PRICE_HIGH, PRICE_LOW + } + + private let repository = ContentRepository() + private var subscription = Set() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var audioContentList: [GetAudioContentListItem] = [] + @Published var totalCount = 0 + @Published var sort = Sort.NEWEST { + didSet { + page = 1 + isLast = false + getAudioContentList() + } + } + + @Published var scrollToTop = false + + var userId = 0 + var page = 1 + var isLast = false + private let pageSize = 10 + + func getAudioContentList() { + if (!isLast && !isLoading) { + isLoading = true + + repository + .getAudioContentList(userId: userId, page: page, size: pageSize, sort: sort) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + if page == 1 { + self.audioContentList.removeAll() + self.scrollToTop.toggle() + } + + if !data.items.isEmpty { + page += 1 + self.totalCount = data.totalCount + self.audioContentList.append(contentsOf: data.items) + } 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 + } + + self.isLoading = false + } + .store(in: &subscription) + } + } +} diff --git a/SodaLive/Sources/Content/ContentRepository.swift b/SodaLive/Sources/Content/ContentRepository.swift new file mode 100644 index 0000000..6251705 --- /dev/null +++ b/SodaLive/Sources/Content/ContentRepository.swift @@ -0,0 +1,79 @@ +// +// ContentRepository.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation +import CombineMoya +import Combine +import Moya + +final class ContentRepository { + private let api = MoyaProvider() + + func getAudioContentList(userId: Int, page: Int, size: Int, sort: ContentListViewModel.Sort) -> AnyPublisher { + return api.requestPublisher(.getAudioContentList(userId: userId, page: page, size: size, sort: sort)) + } + + func getAudioContentDetail(audioContentId: Int) -> AnyPublisher { + return api.requestPublisher(.getAudioContentDetail(audioContentId: audioContentId)) + } + + func likeContent(audioContentId: Int) -> AnyPublisher { + return api.requestPublisher(.likeContent(request: PutAudioContentLikeRequest(audioContentId: audioContentId))) + } + + func registerComment(audioContentId: Int, comment: String, parentId: Int? = nil) -> AnyPublisher { + return api.requestPublisher(.registerComment(request: RegisterAudioContentCommentRequest(comment: comment, audioContentId: audioContentId, parentId: parentId))) + } + + func orderAudioContent(audioContentId: Int, orderType: OrderType) -> AnyPublisher { + return api.requestPublisher(.orderAudioContent(request: OrderRequest(audioContentId: audioContentId, orderType: orderType))) + } + + func getOrderList(page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getOrderList(page: page, size: size)) + } + + func addAllPlaybackTracking(request: AddAllPlaybackTrackingRequest) -> AnyPublisher { + return api.requestPublisher(.addAllPlaybackTracking(request: request)) + } + + func getAudioContentThemeList() -> AnyPublisher { + return api.requestPublisher(.getAudioContentThemeList) + } + + func uploadAudioContent(parameters: [MultipartFormData]) -> AnyPublisher { + return api.requestPublisher(.uploadAudioContent(parameters: parameters)) + } + + func getAudioContentCommentList(audioContentId: Int, page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getAudioContentCommentList(audioContentId: audioContentId, page: page, size: size)) + } + + func getAudioContentCommentReplyList(commentId: Int, page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getAudioContentCommentReplyList(commentId: commentId, page: page, size: size)) + } + + func deleteAudioContent(audioContentId: Int) -> AnyPublisher { + return api.requestPublisher(.deleteAudioContent(audioContentId: audioContentId)) + } + + func modifyAudioContent(parameters: [MultipartFormData]) -> AnyPublisher { + return api.requestPublisher(.modifyAudioContent(parameters: parameters)) + } + + func getMain() -> AnyPublisher { + return api.requestPublisher(.getMain) + } + + func getNewContentOfTheme(theme: String) -> AnyPublisher { + return api.requestPublisher(.getNewContentOfTheme(theme: theme)) + } + + func donation(contentId: Int, coin: Int, comment: String) -> AnyPublisher { + return api.requestPublisher(.donation(request: AudioContentDonationRequest(audioContentId: contentId, donationCoin: coin, comment: comment))) + } +} diff --git a/SodaLive/Sources/Content/Create/ContentCreateSelectThemeView.swift b/SodaLive/Sources/Content/Create/ContentCreateSelectThemeView.swift new file mode 100644 index 0000000..471a535 --- /dev/null +++ b/SodaLive/Sources/Content/Create/ContentCreateSelectThemeView.swift @@ -0,0 +1,88 @@ +// +// ContentCreateSelectThemeView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import Kingfisher + +struct ContentCreateSelectThemeView: View { + + let columns = [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ] + + @StateObject var viewModel = ContentCreateSelectThemeViewModel() + + @Binding var isShowing: Bool + @Binding var selectedTheme: GetAudioContentThemeResponse? + + var body: some View { + ZStack { + Color(hex: "222222").ignoresSafeArea() + + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 0) { + Text("테마 선택") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.white) + + 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.themes, id: \.self) { theme in + VStack(spacing: 16.7) { + KFImage(URL(string: theme.image)) + .resizable() + .scaledToFill() + .frame(width: 60, height: 60, alignment: .top) + .clipShape(Circle()) + + Text(theme.theme) + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "bbbbbb")) + } + .onTapGesture { + selectedTheme = theme + isShowing = false + } + } + } + } + .padding(.horizontal, 20) + .padding(.top, 26.7) + } + + if viewModel.isLoading { + LoadingView() + } + } + .cornerRadius(16.7, corners: [.topLeft, .topRight]) + .onAppear { + viewModel.getThemes() + } + } +} + +struct ContentCreateSelectThemeView_Previews: PreviewProvider { + static var previews: some View { + ContentCreateSelectThemeView( + isShowing: .constant(true), + selectedTheme: .constant(GetAudioContentThemeResponse(id: 1, theme: "", image: "")) + ) + } +} diff --git a/SodaLive/Sources/Content/Create/ContentCreateSelectThemeViewModel.swift b/SodaLive/Sources/Content/Create/ContentCreateSelectThemeViewModel.swift new file mode 100644 index 0000000..b94ed38 --- /dev/null +++ b/SodaLive/Sources/Content/Create/ContentCreateSelectThemeViewModel.swift @@ -0,0 +1,59 @@ +// +// ContentCreateSelectThemeViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation +import Combine + +final class ContentCreateSelectThemeViewModel: ObservableObject { + + private let repository = ContentRepository() + private var subscription = Set() + + @Published var isLoading = false + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var themes: [GetAudioContentThemeResponse] = [] + + func getThemes() { + isLoading = true + + repository.getAudioContentThemeList() + .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<[GetAudioContentThemeResponse]>.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.themes.removeAll() + self.themes.append(contentsOf: data) + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } +} diff --git a/SodaLive/Sources/Content/Create/ContentCreateView.swift b/SodaLive/Sources/Content/Create/ContentCreateView.swift new file mode 100644 index 0000000..ba34e77 --- /dev/null +++ b/SodaLive/Sources/Content/Create/ContentCreateView.swift @@ -0,0 +1,463 @@ +// +// ContentCreateView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import Kingfisher + +struct ContentCreateView: View { + + @StateObject var keyboardHandler = KeyboardHandler() + @StateObject private var viewModel = ContentCreateViewModel() + + @State private var isShowPhotoPicker = false + @State private var isShowSelectAudioView = false + @State private var isShowSelectThemeView = false + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + GeometryReader { proxy in + ZStack { + VStack(spacing: 0) { + DetailNavigationBar(title: "콘텐츠 등록") + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + Text("썸네일") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(maxWidth: .infinity, alignment: .leading) + + ZStack { + if let selectedImage = viewModel.coverImage { + Image(uiImage: selectedImage) + .resizable() + .scaledToFill() + .frame(width: 107, height: 107) + .background(Color(hex: "3e3358")) + .cornerRadius(8) + .clipped() + } else { + Image("ic_logo") + .resizable() + .scaledToFit() + .padding(13.3) + .frame(width: 107, height: 107) + .background(Color(hex: "3e3358")) + .cornerRadius(8) + } + + Image("ic_camera") + .padding(10) + .background(Color(hex: "9970ff")) + .cornerRadius(30) + .offset(x: 50, y: 36) + } + .frame(alignment: .bottomTrailing) + .onTapGesture { isShowPhotoPicker = true } + + Text("등록") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 13.3) + + Text(viewModel.fileName.trimmingCharacters(in: .whitespacesAndNewlines) == "" ? "파일선택" : viewModel.fileName) + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "9970ff")) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(Color(hex: "9970ff").opacity(0.2)) + .cornerRadius(5.3) + .overlay( + RoundedCorner(radius: 8) + .stroke(lineWidth: 2) + .foregroundColor(Color(hex: "9970ff")) + ) + .padding(.top, 13.3) + .onTapGesture { isShowSelectAudioView = true } + } + .padding(.top, 13.3) + .padding(.horizontal, 13.3) + + Rectangle() + .foregroundColor(Color(hex: "232323")) + .frame(height: 6.7) + .padding(.top, 26.7) + + VStack(spacing: 0) { + Text("제목") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(maxWidth: .infinity, alignment: .leading) + + TextField("제목을 입력하세요", text: $viewModel.title) + .autocapitalization(.none) + .disableAutocorrection(true) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.vertical, 16.7) + .padding(.horizontal, 13.3) + .background(Color(hex: "222222")) + .cornerRadius(6.7) + .keyboardType(.default) + .padding(.top, 13.3) + + HStack(spacing: 0) { + Text("내용") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Text("\(viewModel.detail.count)자") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "ff5c49")) + + Text(" / 최대 500자") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "777777")) + } + .padding(.top, 26.7) + + TextViewWrapper( + text: $viewModel.detail, + placeholder: viewModel.placeholder, + textColorHex: "eeeeee", + backgroundColorHex: "222222" + ) + .frame(height: 184) + .cornerRadius(6.7) + .padding(.top, 13.3) + + Text("테마") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 26.7) + + HStack(spacing: 13.3) { + if let theme = viewModel.theme { + KFImage(URL(string: theme.image)) + .resizable() + .frame(width: 33.3, height: 33.3) + .clipShape(Circle()) + } + + Text(viewModel.theme != nil ? + viewModel.theme!.theme : + "테마 선택") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "9970ff")) + } + .padding(.vertical, viewModel.theme != nil ? 8 : 13.3) + .frame(maxWidth: .infinity) + .background(Color(hex: "9970ff").opacity(0.2)) + .cornerRadius(24) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(lineWidth: 2) + .foregroundColor(Color(hex: "9970ff")) + ) + .padding(.top, 13.3) + .onTapGesture { + isShowSelectThemeView = true + hideKeyboard() + } + + Text("태그") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 26.7) + + TextField("예: #연애 #커버곡", text: $viewModel.hashtags) + .autocapitalization(.none) + .disableAutocorrection(true) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.vertical, 16.7) + .padding(.horizontal, 13.3) + .background(Color(hex: "222222")) + .cornerRadius(6.7) + .keyboardType(.default) + .padding(.top, 13.3) + } + .padding(.top, 26.7) + .padding(.horizontal, 13.3) + + Rectangle() + .foregroundColor(Color(hex: "232323")) + .frame(height: 6.7) + .padding(.top, 26.7) + + VStack(spacing: 13.3) { + Text("가격설정") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 13.3) { + SelectButtonView(title: "무료", isChecked: viewModel.isFree) { + if !viewModel.isFree { + viewModel.isFree = true + } + } + + SelectButtonView(title: "유료", isChecked: !viewModel.isFree) { + if viewModel.isFree { + viewModel.isFree = false + } + } + } + + if !viewModel.isFree { + VStack(spacing: 0) { + Text("소장가격") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "d2d2d2")) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 0) { + TextField("가격을 입력하세요(10코인 이상)", text: $viewModel.priceString) + .autocapitalization(.none) + .disableAutocorrection(true) + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + .cornerRadius(6.7) + .keyboardType(.numberPad) + .padding(.trailing, 10) + + Spacer() + + Text("코인") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "777777")) + } + .padding(.vertical, 17) + .padding(.horizontal, 13.3) + .background(Color(hex: "222222")) + .cornerRadius(5.3) + .padding(.top, 5.3) + + Rectangle() + .foregroundColor(Color(hex: "232323")) + .frame(height: 1) + .padding(.top, 11) + + Text("※ 이용기간 대여 (7일) | 소장 (서비스종료시까지)") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "777777")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 13.3) + + Text("※ 대여가격은 소장가격의 70%로 자동 반영") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "777777")) + .frame(maxWidth: .infinity, alignment: .leading) + + Text("※ 콘텐츠의 최소금액은 10코인 입니다") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "777777")) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.top, 26.7) + } + } + .padding(.top, 26.7) + .padding(.horizontal, 13.3) + + VStack(spacing: 13.3) { + Text("연령 제한") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 13.3) { + SelectButtonView(title: "전체 연령", isChecked: !viewModel.isAdult) { + if viewModel.isAdult { + viewModel.isAdult = false + } + } + + SelectButtonView(title: "19세 이상", isChecked: viewModel.isAdult) { + if !viewModel.isAdult { + viewModel.isAdult = true + } + } + } + + Text("성인콘텐츠를 전체관람가로 등록할 시 발생하는 법적 책임은 회사와 상관없이 콘텐츠를 등록한 본인에게 있습니다.\n콘텐츠 내용은 물론 제목도 19금 여부를 체크해 주시기 바랍니다.") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "DD4500")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 13.3) + } + .padding(.top, 26.7) + .padding(.horizontal, 13.3) + + VStack(spacing: 13.3) { + Text("댓글 가능 여부") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 13.3) { + SelectButtonView(title: "댓글 가능", isChecked: viewModel.isAvailableComment) { + if !viewModel.isAvailableComment { + viewModel.isAvailableComment = true + } + } + + SelectButtonView(title: "댓글 불가", isChecked: !viewModel.isAvailableComment) { + if viewModel.isAvailableComment { + viewModel.isAvailableComment = false + } + } + } + } + .padding(.top, 26.7) + .padding(.horizontal, 13.3) + + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 0) { + Text("등록") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color.white) + .frame(height: 50) + .frame(maxWidth: .infinity) + .background(Color(hex: "9970ff")) + .cornerRadius(10) + .padding(13.3) + } + .frame(maxWidth: .infinity) + .background(Color(hex: "222222")) + .cornerRadius(16.7, corners: [.topLeft, .topRight]) + .onTapGesture { + hideKeyboard() + viewModel.uploadAudioContent() + } + + Rectangle() + .foregroundColor(Color(hex: "222222")) + .frame(height: keyboardHandler.keyboardHeight) + .frame(maxWidth: .infinity) + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(Color(hex: "222222")) + .frame(height: 15.3) + .frame(maxWidth: .infinity) + } + } + .padding(.top, 30) + } + } + .fileImporter( + isPresented: $isShowSelectAudioView, + allowedContentTypes: [.audio], + allowsMultipleSelection: false + ) { result in + switch result { + case .success(let url): + // Handle selected file URL + viewModel.selectedFileUrl = url[0] + case .failure(let error): + // Handle error if needed + print("File import error: \(error.localizedDescription)") + } + } + + if isShowPhotoPicker { + ImagePicker( + isShowing: $isShowPhotoPicker, + selectedImage: $viewModel.coverImage, + sourceType: .photoLibrary + ) + } + + if viewModel.isShowCompletePopup { + SodaDialog( + title: "콘텐츠 업로드", + desc: "등록한 콘텐츠가 업로드 중입니다.\n" + + "콘텐츠 등록이 완료되면 알림을 보내드립니다.\n" + + "이 페이지를 나가도 콘텐츠는 자동으로 등록됩니다.", + confirmButtonTitle: "확인", + confirmButtonAction: { AppState.shared.back() }, + cancelButtonTitle: "", + cancelButtonAction: {} + ) + } + + GeometryReader { proxy in + VStack { + Spacer() + ContentCreateSelectThemeView( + isShowing: $isShowSelectThemeView, + selectedTheme: $viewModel.theme + ) + .frame(width: proxy.size.width, height: proxy.size.height * 0.9) + .offset(y: isShowSelectThemeView ? 0 : proxy.size.height * 0.9) + .animation(.easeInOut(duration: 0.49), value: self.isShowSelectThemeView) + } + } + .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: screenSize().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() + } + } + } + } + } + } +} + +struct SelectButtonView: View { + + let title: String + let isChecked: Bool + let action: () -> Void + + var body: some View { + HStack(spacing: 6.7) { + if isChecked { + Image("ic_select_check") + } + + Text(title) + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(isChecked ? .white : Color(hex: "9970ff")) + } + .frame(height: 48.7) + .frame(maxWidth: .infinity) + .background(isChecked ? Color(hex: "9970ff") : Color(hex: "1f1734")) + .cornerRadius(6.7) + .onTapGesture { + hideKeyboard() + action() + } + } +} + +struct ContentCreateView_Previews: PreviewProvider { + static var previews: some View { + ContentCreateView() + } +} diff --git a/SodaLive/Sources/Content/Create/ContentCreateViewModel.swift b/SodaLive/Sources/Content/Create/ContentCreateViewModel.swift new file mode 100644 index 0000000..7974a48 --- /dev/null +++ b/SodaLive/Sources/Content/Create/ContentCreateViewModel.swift @@ -0,0 +1,216 @@ +// +// ContentCreateViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import UIKit +import Moya +import Combine + +final class ContentCreateViewModel: ObservableObject { + + private let repository = ContentRepository() + private var subscription = Set() + + @Published var isLoading = false + @Published var isShowCompletePopup = false + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var fileName = "" + + @Published var title = "" + @Published var detail = "" + @Published var hashtags: String = "" + @Published var theme: GetAudioContentThemeResponse? = nil + @Published var coverImage: UIImage? = nil + @Published var selectedFileUrl: URL? = nil { + didSet { + if let fileUrl = selectedFileUrl { + fileName = fileUrl.lastPathComponent + } + } + } + + @Published var isAvailableComment = true + @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 isFree = true { + didSet { + if isFree { + priceString = "0" + } + } + } + + var placeholder = "내용을 입력하세요" + + func uploadAudioContent() { + if !isLoading && validateData() { + isLoading = true + + let request = CreateAudioContentRequest( + title: title, + detail: detail, + tags: hashtags, + price: price, + themeId: theme!.id, + isAdult: isAdult, + isCommentAvailable: isAvailableComment + ) + + 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/*") + ) + } else { + errorMessage = "커버이미지를 업로드 하지 못했습니다.\n다시 선택해 주세요" + isShowPopup = true + isLoading = false + return + } + + if let selectedFileUrl = selectedFileUrl { + if selectedFileUrl.startAccessingSecurityScopedResource() { + defer { + selectedFileUrl.stopAccessingSecurityScopedResource() + } + + if let data = try? Data(contentsOf: selectedFileUrl) { + multipartData.append( + MultipartFormData( + provider: .data(data), + name: "contentFile", + fileName: selectedFileUrl.lastPathComponent, + mimeType: "audio/*" + ) + ) + } else { + errorMessage = "콘텐츠 파일을 업로드 하지 못했습니다.\n다시 선택해 주세요" + isShowPopup = true + isLoading = false + return + } + } else { + errorMessage = "콘텐츠 파일을 업로드 하지 못했습니다.\n다시 선택해 주세요" + isShowPopup = true + isLoading = false + return + } + } else { + errorMessage = "콘텐츠 파일을 업로드 하지 못했습니다.\n다시 선택해 주세요" + isShowPopup = true + isLoading = false + return + } + + multipartData.append(MultipartFormData(provider: .data(jsonData), name: "request")) + + repository + .uploadAudioContent(parameters: multipartData) + .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 { + self.isShowCompletePopup = 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) + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + self.isLoading = false + } + } + } + + private func validateData() -> Bool { + if title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + errorMessage = "제목을 입력해 주세요." + isShowPopup = true + return false + } + + if detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || detail.count < 5 { + errorMessage = "내용을 5자 이상 입력해 주세요." + isShowPopup = true + return false + } + + if theme == nil { + errorMessage = "테마를 선택해 주세요." + isShowPopup = true + return false + } + + if coverImage == nil { + errorMessage = "커버이미지를 선택해 주세요." + isShowPopup = true + return false + } + + if selectedFileUrl == nil { + errorMessage = "오디오 콘텐츠를 선택해 주세요." + isShowPopup = true + return false + } + + if !isFree && price < 10 { + errorMessage = "콘텐츠의 최소금액은 10코인 입니다." + isShowPopup = true + return false + } + + return true + } +} diff --git a/SodaLive/Sources/Content/Create/CreateAudioContentRequest.swift b/SodaLive/Sources/Content/Create/CreateAudioContentRequest.swift new file mode 100644 index 0000000..98bc462 --- /dev/null +++ b/SodaLive/Sources/Content/Create/CreateAudioContentRequest.swift @@ -0,0 +1,18 @@ +// +// CreateAudioContentRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct CreateAudioContentRequest: Encodable { + let title: String + let detail: String + let tags: String + let price: Int + let themeId: Int + let isAdult: Bool + let isCommentAvailable: Bool +} diff --git a/SodaLive/Sources/Content/Create/GetAudioContentThemeResponse.swift b/SodaLive/Sources/Content/Create/GetAudioContentThemeResponse.swift new file mode 100644 index 0000000..fde0682 --- /dev/null +++ b/SodaLive/Sources/Content/Create/GetAudioContentThemeResponse.swift @@ -0,0 +1,14 @@ +// +// GetAudioContentThemeResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct GetAudioContentThemeResponse: Decodable, Hashable { + let id: Int + let theme: String + let image: String +} diff --git a/SodaLive/Sources/Content/Detail/Comment/GetAudioContentCommentListResponse.swift b/SodaLive/Sources/Content/Detail/Comment/GetAudioContentCommentListResponse.swift new file mode 100644 index 0000000..0f2095c --- /dev/null +++ b/SodaLive/Sources/Content/Detail/Comment/GetAudioContentCommentListResponse.swift @@ -0,0 +1,24 @@ +// +// GetAudioContentCommentListResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct GetAudioContentCommentListResponse: Decodable { + let totalCount: Int + let items: [GetAudioContentCommentListItem] +} + +struct GetAudioContentCommentListItem: Decodable { + let id: Int + let writerId: Int + let nickname: String + let profileUrl: String + let comment: String + let donationCoin: Int + let date: String + let replyCount: Int +} diff --git a/SodaLive/Sources/Content/Detail/Comment/RegisterAudioContentCommentRequest.swift b/SodaLive/Sources/Content/Detail/Comment/RegisterAudioContentCommentRequest.swift new file mode 100644 index 0000000..fa9e682 --- /dev/null +++ b/SodaLive/Sources/Content/Detail/Comment/RegisterAudioContentCommentRequest.swift @@ -0,0 +1,14 @@ +// +// RegisterAudioContentCommentRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct RegisterAudioContentCommentRequest: Encodable { + let comment: String + let audioContentId: Int + let parentId: Int? +} diff --git a/SodaLive/Sources/Content/Detail/GetAudioContentDetailResponse.swift b/SodaLive/Sources/Content/Detail/GetAudioContentDetailResponse.swift new file mode 100644 index 0000000..3ba61fe --- /dev/null +++ b/SodaLive/Sources/Content/Detail/GetAudioContentDetailResponse.swift @@ -0,0 +1,50 @@ +// +// GetAudioContentDetailResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct GetAudioContentDetailResponse: Decodable { + let contentId: Int + let title: String + let detail: String + let coverImageUrl: String + let contentUrl: String + let themeStr: String + let tag: String + let price: Int + let duration: String + let isAdult: Bool + let isMosaic: Bool + let existOrdered: Bool + let orderType: OrderType? + let remainingTime: String? + let creatorOtherContentList: [OtherContentResponse] + let sameThemeOtherContentList: [OtherContentResponse] + let isCommentAvailable: Bool + let isLike: Bool + let likeCount: Int + let commentList: [GetAudioContentCommentListItem] + let commentCount: Int + let creator: AudioContentCreator +} + +enum OrderType: String, Codable { + case RENTAL, KEEP +} + +struct OtherContentResponse: Decodable { + let contentId: Int + let title: String + let coverUrl: String +} + +struct AudioContentCreator: Decodable { + let creatorId: Int + let nickname: String + let profileImageUrl: String + let isFollowing: Bool +} diff --git a/SodaLive/Sources/Content/Detail/PutAudioContentLikeRequest.swift b/SodaLive/Sources/Content/Detail/PutAudioContentLikeRequest.swift new file mode 100644 index 0000000..652777b --- /dev/null +++ b/SodaLive/Sources/Content/Detail/PutAudioContentLikeRequest.swift @@ -0,0 +1,14 @@ +// +// PutAudioContentLikeRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +struct PutAudioContentLikeRequest: Encodable { + let audioContentId: Int +} + +struct PutAudioContentLikeResponse: Decodable { + let like: Bool +} diff --git a/SodaLive/Sources/Content/Donation/AudioContentDonationRequest.swift b/SodaLive/Sources/Content/Donation/AudioContentDonationRequest.swift new file mode 100644 index 0000000..98bf078 --- /dev/null +++ b/SodaLive/Sources/Content/Donation/AudioContentDonationRequest.swift @@ -0,0 +1,15 @@ +// +// AudioContentDonationRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct AudioContentDonationRequest: Encodable { + let audioContentId: Int + let donationCoin: Int + let comment: String + let container: String = "ios" +} diff --git a/SodaLive/Sources/Content/Main/ContentMainBannerView.swift b/SodaLive/Sources/Content/Main/ContentMainBannerView.swift new file mode 100644 index 0000000..4034716 --- /dev/null +++ b/SodaLive/Sources/Content/Main/ContentMainBannerView.swift @@ -0,0 +1,114 @@ +// +// ContentMainBannerView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import Kingfisher + +struct ContentMainBannerView: View { + + let items: [GetAudioContentBannerResponse] + @State private var currentIndex = 0 + @State private var timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() + + var body: some View { + VStack(spacing: 0) { + TabView(selection: $currentIndex) { + ForEach(0.. 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } + } + .cornerRadius(4.7) + } else { + KFImage(URL(string: item.thumbnailImageUrl)) + .resizable() + .scaledToFill() + .frame( + width: screenSize().width - 26.7, + height: (screenSize().width - 26.7) * 0.53 + ) + .onTapGesture { + switch item.type { + case .EVENT: + AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!)) + case .CREATOR: + break + case .LINK: + if let link = item.link, link.trimmingCharacters(in: .whitespaces).count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } + } + .cornerRadius(4.7) + } + } + } + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) + .frame( + width: screenSize().width - 26.7, + height: (screenSize().width - 26.7) * 0.53 + ) + + HStack(spacing: 4) { + ForEach(0.. Void + + @Binding var selectedTheme: String + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 8) { + ForEach(0.. Void + @Binding var selectedTheme: String + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("새로운 콘텐츠") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "eeeeee")) + + ContentMainNewContentThemeView(themes: themes, selectTheme: selectTheme, selectedTheme: $selectedTheme) + .padding(.vertical, 16.7) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 13.3) { + ForEach(0.. 0 { + ContentMainNewContentCreatorView(items: viewModel.newContentUploadCreatorList) + .padding(.bottom, 26.7) + } + + if viewModel.bannerList.count > 0 { + ContentMainBannerView(items: viewModel.bannerList) + .padding(.bottom, 40) + } + + if viewModel.orderList.count > 0 { + ContentMainMyStashView(items: viewModel.orderList) + .padding(.bottom, 40) + } + + ContentMainNewContentView( + themes: viewModel.themeList, + items: viewModel.newContentList, + selectTheme: { viewModel.selectedTheme = $0 }, + selectedTheme: $viewModel.selectedTheme + ) + + if viewModel.curationList.count > 0 { + ContentMainCurationView(items: viewModel.curationList) + .padding(.top, 40) + .padding(.bottom, 20) + } + } + .padding(13.3) + } + + if UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue { + HStack(spacing: 5) { + Image("ic_thumb_play") + .resizable() + .frame(width: 20, height: 20) + + Text("콘텐츠 업로드") + .font(.custom(Font.bold.rawValue, size: 13.3)) + .foregroundColor(.white) + } + .padding(13.3) + .background(Color(hex: "9970ff")) + .cornerRadius(44) + .padding(.trailing, 16.7) + .padding(.bottom, 16.7) + .onTapGesture { + AppState.shared.setAppStep(step: .createContent) + } + } + + if viewModel.isLoading { + 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() + } + } + } + .onAppear { + viewModel.getMain() + } } } diff --git a/SodaLive/Sources/Content/Main/ContentMainViewModel.swift b/SodaLive/Sources/Content/Main/ContentMainViewModel.swift new file mode 100644 index 0000000..3d1e10a --- /dev/null +++ b/SodaLive/Sources/Content/Main/ContentMainViewModel.swift @@ -0,0 +1,124 @@ +// +// ContentMainViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +import Combine + +final class ContentMainViewModel: ObservableObject { + + private let repository = ContentRepository() + private var subscription = Set() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var newContentUploadCreatorList = [GetNewContentUploadCreator]() + @Published var newContentList = [GetAudioContentMainItem]() + @Published var bannerList = [GetAudioContentBannerResponse]() + @Published var orderList = [GetAudioContentMainItem]() + @Published var themeList = [String]() + @Published var curationList = [GetAudioContentCurationResponse]() + + @Published var selectedTheme = "전체" { + didSet { + getNewContentOfTheme() + } + } + + func getMain() { + isLoading = true + + repository.getMain() + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + self.isLoading = false + + if let data = decoded.data, decoded.success { + self.newContentUploadCreatorList.removeAll() + self.newContentList.removeAll() + self.bannerList.removeAll() + self.orderList.removeAll() + self.curationList.removeAll() + self.themeList.removeAll() + + self.newContentUploadCreatorList.append(contentsOf: data.newContentUploadCreatorList) + self.newContentList.append(contentsOf: data.newContentList) + self.bannerList.append(contentsOf: data.bannerList) + self.orderList.append(contentsOf: data.orderList) + self.curationList.append(contentsOf: data.curationList) + + self.themeList.append("전체") + self.themeList.append(contentsOf: data.themeList) + } 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) + } + + func getNewContentOfTheme() { + repository.getNewContentOfTheme(theme: selectedTheme == "전체" ? "" : selectedTheme) + .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<[GetAudioContentMainItem]>.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.newContentList.removeAll() + self.newContentList.append(contentsOf: data) + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } +} diff --git a/SodaLive/Sources/Content/Main/GetAudioContentMainResponse.swift b/SodaLive/Sources/Content/Main/GetAudioContentMainResponse.swift new file mode 100644 index 0000000..011571a --- /dev/null +++ b/SodaLive/Sources/Content/Main/GetAudioContentMainResponse.swift @@ -0,0 +1,51 @@ +// +// GetAudioContentMainResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct GetAudioContentMainResponse: Decodable { + let newContentUploadCreatorList: [GetNewContentUploadCreator] + let bannerList: [GetAudioContentBannerResponse] + let orderList: [GetAudioContentMainItem] + let themeList: [String] + let newContentList: [GetAudioContentMainItem] + let curationList: [GetAudioContentCurationResponse] +} + +struct GetNewContentUploadCreator: Decodable { + let creatorId: Int + let creatorNickname: String + let creatorProfileImageUrl: String +} + +struct GetAudioContentMainItem: Decodable { + let contentId: Int + let coverImageUrl: String + let title: String + let isAdult: Bool + let creatorId: Int + let creatorProfileImageUrl: String + let creatorNickname: String +} + +struct GetAudioContentCurationResponse: Decodable { + let title: String + let description: String + let audioContents: [GetAudioContentMainItem] +} + +struct GetAudioContentBannerResponse: Decodable { + let type: AudioContentBannerType + let thumbnailImageUrl: String + let eventItem: EventItem? + let creatorId: Int? + let link: String? +} + +enum AudioContentBannerType: String, Decodable { + case EVENT, CREATOR, LINK +} diff --git a/SodaLive/Sources/Content/Order/OrderRequest.swift b/SodaLive/Sources/Content/Order/OrderRequest.swift new file mode 100644 index 0000000..90a26b9 --- /dev/null +++ b/SodaLive/Sources/Content/Order/OrderRequest.swift @@ -0,0 +1,14 @@ +// +// OrderRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct OrderRequest: Encodable { + let audioContentId: Int + let orderType: OrderType + let container: String = "ios" +} diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index ba31642..51b0ba9 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -83,6 +83,9 @@ struct ContentView: View { case .serviceCenter: ServiceCenterView() + case .createContent: + ContentCreateView() + default: EmptyView() .frame(width: 0, height: 0, alignment: .topLeading) diff --git a/SodaLive/Sources/Explorer/Profile/GetCreatorProfileResponse.swift b/SodaLive/Sources/Explorer/Profile/GetCreatorProfileResponse.swift new file mode 100644 index 0000000..48c1df5 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/GetCreatorProfileResponse.swift @@ -0,0 +1,25 @@ +// +// GetCreatorProfileResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct GetAudioContentListResponse: Decodable { + let totalCount: Int + let items: [GetAudioContentListItem] +} + +struct GetAudioContentListItem: Decodable { + let contentId: Int + let coverImageUrl: String + let title: String + let price: Int + let themeStr: String + let duration: String? + let likeCount: Int + let commentCount: Int + let isAdult: Bool +}