diff --git a/SodaLive/Resources/Assets.xcassets/ic_pin.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_pin.imageset/Contents.json new file mode 100644 index 0000000..d7e1323 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_pin.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_pin.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_pin.imageset/ic_pin.png b/SodaLive/Resources/Assets.xcassets/ic_pin.imageset/ic_pin.png new file mode 100644 index 0000000..088bb74 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_pin.imageset/ic_pin.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_pin_cancel.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_pin_cancel.imageset/Contents.json new file mode 100644 index 0000000..59bad5e --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_pin_cancel.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_pin_cancel.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_pin_cancel.imageset/ic_pin_cancel.png b/SodaLive/Resources/Assets.xcassets/ic_pin_cancel.imageset/ic_pin_cancel.png new file mode 100644 index 0000000..26586bd Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_pin_cancel.imageset/ic_pin_cancel.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_trash_can.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_trash_can.imageset/Contents.json new file mode 100644 index 0000000..d8a95f8 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_trash_can.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_trash_can.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_trash_can.imageset/ic_trash_can.png b/SodaLive/Resources/Assets.xcassets/ic_trash_can.imageset/ic_trash_can.png new file mode 100644 index 0000000..b3a0532 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_trash_can.imageset/ic_trash_can.png differ diff --git a/SodaLive/Sources/Content/ContentApi.swift b/SodaLive/Sources/Content/ContentApi.swift index c2e422e..fc15a7e 100644 --- a/SodaLive/Sources/Content/ContentApi.swift +++ b/SodaLive/Sources/Content/ContentApi.swift @@ -34,6 +34,8 @@ enum ContentApi { case getAudioContentListByCurationId(curationId: Int, page: Int, size: Int, sort: ContentCurationViewModel.Sort) case getContentRanking(page: Int, size: Int, sortType: String) case getContentRankingSortType + case pinContent(contentId: Int) + case unpinContent(contentId: Int) } extension ContentApi: TargetType { @@ -117,6 +119,12 @@ extension ContentApi: TargetType { case .getContentRankingSortType: return "/audio-content/ranking-sort-type" + + case .pinContent(let contentId): + return "/audio-content/pin-to-the-top/\(contentId)" + + case .unpinContent(let contentId): + return "/audio-content/unpin-at-the-top/\(contentId)" } } @@ -131,10 +139,10 @@ extension ContentApi: TargetType { case .getMainBannerList, .getMainOrderList, .getNewContentUploadCreatorList, .getCurationList: return .get - case .likeContent, .modifyAudioContent, .modifyComment: + case .likeContent, .modifyAudioContent, .modifyComment, .unpinContent: return .put - case .registerComment, .orderAudioContent, .addAllPlaybackTracking, .uploadAudioContent, .donation: + case .registerComment, .orderAudioContent, .addAllPlaybackTracking, .uploadAudioContent, .donation, .pinContent: return .post case .deleteAudioContent: @@ -259,6 +267,9 @@ extension ContentApi: TargetType { ] as [String : Any] return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + + case .pinContent, .unpinContent: + return .requestPlain } } diff --git a/SodaLive/Sources/Content/ContentRepository.swift b/SodaLive/Sources/Content/ContentRepository.swift index 955999c..55c1557 100644 --- a/SodaLive/Sources/Content/ContentRepository.swift +++ b/SodaLive/Sources/Content/ContentRepository.swift @@ -112,4 +112,12 @@ final class ContentRepository { func getContentRanking(page: Int, size: Int, sortType: String = "매출") -> AnyPublisher { return api.requestPublisher(.getContentRanking(page: page, size: size, sortType: sortType)) } + + func pinContent(contentId: Int) -> AnyPublisher { + return api.requestPublisher(.pinContent(contentId: contentId)) + } + + func unpinContent(contentId: Int) -> AnyPublisher { + return api.requestPublisher(.unpinContent(contentId: contentId)) + } } diff --git a/SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift b/SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift index 6207cb4..4d3451f 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift @@ -11,7 +11,10 @@ struct ContentDetailMenuView: View { @Binding var isShowing: Bool + let isPin: Bool let isShowCreatorMenu: Bool + + let pinAction: () -> Void let modifyAction: () -> Void let deleteAction: () -> Void let reportAction: () -> Void @@ -28,7 +31,26 @@ struct ContentDetailMenuView: View { VStack(spacing: 13.3) { if isShowCreatorMenu { - HStack(spacing: 0) { + HStack(spacing: 13.3) { + Image(isPin ? "ic_pin_cancel" : "ic_pin") + + Text(isPin ? "내 채널에 고정 취소" : "내 채널에 고정") + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(.white) + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 26.7) + .contentShape(Rectangle()) + .onTapGesture { + isShowing = false + pinAction() + } + + HStack(spacing: 13.3) { + Image("ic_make_message") + Text("수정") .font(.custom(Font.medium.rawValue, size: 16.7)) .foregroundColor(.white) @@ -43,7 +65,9 @@ struct ContentDetailMenuView: View { modifyAction() } - HStack(spacing: 0) { + HStack(spacing: 13.3) { + Image("ic_trash_can") + Text("삭제") .font(.custom(Font.medium.rawValue, size: 16.7)) .foregroundColor(.white) diff --git a/SodaLive/Sources/Content/Detail/ContentDetailView.swift b/SodaLive/Sources/Content/Detail/ContentDetailView.swift index 0330de2..ffceeb9 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailView.swift @@ -219,7 +219,19 @@ struct ContentDetailView: View { VStack(spacing: 0) { ContentDetailMenuView( isShowing: $viewModel.isShowReportMenu, + isPin: viewModel.audioContent!.isPin, isShowCreatorMenu: viewModel.audioContent!.creator.creatorId == UserDefaults.int(forKey: .userId), + pinAction: { + if viewModel.audioContent!.isPin { + viewModel.unpinContent(contentId: contentId) + } else { + if viewModel.audioContent!.isAvailablePin { + viewModel.pinContent(contentId: contentId) + } else { + viewModel.isShowNoticePinContentPopup = true + } + } + }, modifyAction: { if viewModel.audioContent!.creator.creatorId == UserDefaults.int(forKey: .userId) { AppState @@ -284,6 +296,21 @@ struct ContentDetailView: View { viewModel.donation(can: can, comment: comment) } } + + if viewModel.isShowNoticePinContentPopup { + SodaDialog( + title: "고정 한도 도달", + desc: "이 콘텐츠를 고정하시겠어요? " + + "채널에 콘텐츠를 최대 3개까지 고정할 수 있습니다." + + "이 콘텐츠를 고정하면 가장 오래된 콘텐츠가 대체됩니다.", + confirmButtonTitle: "확인", + confirmButtonAction: { + viewModel.pinContent(contentId: contentId) + }, + cancelButtonTitle: "취소", + cancelButtonAction: {} + ) + } } } .sheet( diff --git a/SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift b/SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift index 4a48764..e840677 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift @@ -34,6 +34,7 @@ final class ContentDetailViewModel: ObservableObject { @Published var isShowReportMenu = false @Published var isShowReportView = false @Published var isShowDeleteConfirm = false + @Published var isShowNoticePinContentPopup = false var contentId: Int = 0 { didSet { @@ -448,4 +449,84 @@ final class ContentDetailViewModel: ObservableObject { .store(in: &subscription) } } + + func pinContent(contentId: Int) { + isLoading = true + repository.pinContent(contentId: contentId) + .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.errorMessage = "고정되었습니다" + self.isShowPopup = true + + self.getAudioContentDetail() + } 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 unpinContent(contentId: Int) { + isLoading = true + repository.unpinContent(contentId: contentId) + .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.errorMessage = "해제되었습니다" + self.isShowPopup = true + + self.getAudioContentDetail() + } 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/Detail/GetAudioContentDetailResponse.swift b/SodaLive/Sources/Content/Detail/GetAudioContentDetailResponse.swift index f84d032..923566a 100644 --- a/SodaLive/Sources/Content/Detail/GetAudioContentDetailResponse.swift +++ b/SodaLive/Sources/Content/Detail/GetAudioContentDetailResponse.swift @@ -32,6 +32,8 @@ struct GetAudioContentDetailResponse: Decodable { let likeCount: Int let commentList: [GetAudioContentCommentListItem] let commentCount: Int + let isPin: Bool + let isAvailablePin: Bool let creator: AudioContentCreator } diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift index 057ddf4..ae2370f 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift @@ -67,7 +67,7 @@ struct CreatorCommunityAllView: View { ZStack { if viewModel.isShowReportMenu { VStack(spacing: 0) { - ContentDetailMenuView( + CreatorCommunityMenuView( isShowing: $viewModel.isShowReportMenu, isShowCreatorMenu: creatorId == UserDefaults.int(forKey: .userId), modifyAction: { diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityMenuView.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityMenuView.swift new file mode 100644 index 0000000..ad820cc --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityMenuView.swift @@ -0,0 +1,97 @@ +// +// CreatorCommunityMenuView.swift +// SodaLive +// +// Created by klaus on 1/29/24. +// + +import SwiftUI + +struct CreatorCommunityMenuView: View { + @Binding var isShowing: Bool + + let isShowCreatorMenu: Bool + + let modifyAction: () -> Void + let deleteAction: () -> Void + let reportAction: () -> Void + + var body: some View { + ZStack { + Color.black + .opacity(0.7) + .ignoresSafeArea() + .onTapGesture { isShowing = false } + + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 13.3) { + if isShowCreatorMenu { + HStack(spacing: 13.3) { + Image("ic_make_message") + + Text("수정") + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(.white) + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 26.7) + .contentShape(Rectangle()) + .onTapGesture { + isShowing = false + modifyAction() + } + + HStack(spacing: 13.3) { + Image("ic_trash_can") + + Text("삭제") + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(.white) + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 26.7) + .contentShape(Rectangle()) + .onTapGesture { + isShowing = false + deleteAction() + } + } else { + HStack(spacing: 0) { + Text("신고") + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(.white) + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 26.7) + .contentShape(Rectangle()) + .onTapGesture { + isShowing = false + reportAction() + } + } + } + .padding(24) + .background(Color(hex: "222222")) + .cornerRadius(13.3, corners: [.topLeft, .topRight]) + } + } + } +} + +#Preview { + CreatorCommunityMenuView( + isShowing: .constant(true), + isShowCreatorMenu: true, + modifyAction: {}, + deleteAction: {}, + reportAction: {} + ) +}