diff --git a/SodaLive/Resources/Assets.xcassets/btn_message_send.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_message_send.imageset/Contents.json new file mode 100644 index 0000000..192ff33 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_message_send.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "btn_message_send.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_message_send.imageset/btn_message_send.png b/SodaLive/Resources/Assets.xcassets/btn_message_send.imageset/btn_message_send.png new file mode 100644 index 0000000..5fc9f6c Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_message_send.imageset/btn_message_send.png differ diff --git a/SodaLive/Resources/Assets.xcassets/btn_notification.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_notification.imageset/Contents.json new file mode 100644 index 0000000..d957bae --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_notification.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "btn_notification.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_notification.imageset/btn_notification.png b/SodaLive/Resources/Assets.xcassets/btn_notification.imageset/btn_notification.png new file mode 100644 index 0000000..4508191 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_notification.imageset/btn_notification.png differ diff --git a/SodaLive/Resources/Assets.xcassets/btn_notification_selected.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_notification_selected.imageset/Contents.json new file mode 100644 index 0000000..375f570 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_notification_selected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "btn_notification_selected.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_notification_selected.imageset/btn_notification_selected.png b/SodaLive/Resources/Assets.xcassets/btn_notification_selected.imageset/btn_notification_selected.png new file mode 100644 index 0000000..6f17a44 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_notification_selected.imageset/btn_notification_selected.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_crown.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_crown.imageset/Contents.json new file mode 100644 index 0000000..3bdb10e --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_crown.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_crown.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_crown.imageset/ic_crown.png b/SodaLive/Resources/Assets.xcassets/ic_crown.imageset/ic_crown.png new file mode 100644 index 0000000..003ab3c Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_crown.imageset/ic_crown.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_crown_1.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_crown_1.imageset/Contents.json new file mode 100644 index 0000000..0e50325 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_crown_1.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_crown_1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_crown_1.imageset/ic_crown_1.png b/SodaLive/Resources/Assets.xcassets/ic_crown_1.imageset/ic_crown_1.png new file mode 100644 index 0000000..3ad440c Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_crown_1.imageset/ic_crown_1.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_crown_2.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_crown_2.imageset/Contents.json new file mode 100644 index 0000000..09d3999 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_crown_2.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_crown_2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_crown_2.imageset/ic_crown_2.png b/SodaLive/Resources/Assets.xcassets/ic_crown_2.imageset/ic_crown_2.png new file mode 100644 index 0000000..ee2874d Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_crown_2.imageset/ic_crown_2.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_crown_3.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_crown_3.imageset/Contents.json new file mode 100644 index 0000000..125a50f --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_crown_3.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_crown_3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_crown_3.imageset/ic_crown_3.png b/SodaLive/Resources/Assets.xcassets/ic_crown_3.imageset/ic_crown_3.png new file mode 100644 index 0000000..95838a4 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_crown_3.imageset/ic_crown_3.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_seemore_vertical.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_seemore_vertical.imageset/Contents.json new file mode 100644 index 0000000..a3bee2b --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_seemore_vertical.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_seemore_vertical.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_seemore_vertical.imageset/ic_seemore_vertical.png b/SodaLive/Resources/Assets.xcassets/ic_seemore_vertical.imageset/ic_seemore_vertical.png new file mode 100644 index 0000000..bc34f59 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_seemore_vertical.imageset/ic_seemore_vertical.png differ diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 753c7cc..edd0611 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -55,4 +55,16 @@ enum AppStep { case serviceCenter case createContent + + case liveReservationComplete(response: MakeLiveReservationResponse) + + case creatorDetail(userId: Int) + + case followerList(userId: Int) + + case userProfileDonationAll(userId: Int) + + case userProfileFanTalkAll(userId: Int) + + case creatorNoticeWrite(notice: String) } diff --git a/SodaLive/Sources/Common/ActivityViewController.swift b/SodaLive/Sources/Common/ActivityViewController.swift new file mode 100644 index 0000000..4e049cf --- /dev/null +++ b/SodaLive/Sources/Common/ActivityViewController.swift @@ -0,0 +1,28 @@ +// +// ActivityViewController.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI + +struct ActivityViewController: UIViewControllerRepresentable { + var activityItems: [Any] + var applicationActivities: [UIActivity]? = nil + @Environment(\.presentationMode) var presentationMode + + func makeUIViewController(context: Context) -> some UIActivityViewController { + let controller = UIActivityViewController( + activityItems: activityItems, + applicationActivities: applicationActivities + ) + controller.completionWithItemsHandler = { (activityType, completed, returnedItems, error) in + self.presentationMode.wrappedValue.dismiss() + } + return controller + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + } +} diff --git a/SodaLive/Sources/Content/ContentListItemView.swift b/SodaLive/Sources/Content/ContentListItemView.swift new file mode 100644 index 0000000..c7a996e --- /dev/null +++ b/SodaLive/Sources/Content/ContentListItemView.swift @@ -0,0 +1,130 @@ +// +// ContentListItemView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import Kingfisher + +struct ContentListItemView: View { + + let item: GetAudioContentListItem + + var body: some View { + VStack(spacing: 10) { + HStack(spacing: 0) { + ZStack(alignment: .topLeading) { + KFImage(URL(string: item.coverImageUrl)) + .resizable() + .scaledToFill() + .frame(width: 66.7, height: 66.7, alignment: .top) + .clipped() + .cornerRadius(5.3) + + 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(.top, 4.3) + .padding(.leading, 4.3) + } + } + + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Text(item.themeStr) + .font(.custom(Font.medium.rawValue, size: 8)) + .foregroundColor(Color(hex: "3bac6a")) + .padding(2.6) + .background(Color(hex: "28312b")) + .cornerRadius(2.6) + + Text(item.duration!) + .font(.custom(Font.medium.rawValue, size: 8)) + .foregroundColor(Color(hex: "777777")) + .padding(2.6) + .background(Color(hex: "222222")) + .cornerRadius(2.6) + } + + Text(item.title) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "d2d2d2")) + .padding(.top, 8) + .padding(.bottom, 10) + + HStack(spacing: 13.3) { + HStack(spacing: 6) { + Image("ic_heart") + .resizable() + .frame(width: 13.3, height: 13.3) + + Text("\(item.likeCount)") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "777777")) + } + + HStack(spacing: 6) { + Image("ic_message_square_777") + .resizable() + .frame(width: 13.3, height: 13.3) + + Text("\(item.commentCount)") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "777777")) + } + } + } + .padding(.leading, 10.7) + .padding(.top, 8) + .padding(.bottom, 12) + + Spacer() + + if item.price > 0 { + HStack(spacing: 8) { + Image("ic_coin_w") + .resizable() + .frame(width: 17, height: 17) + + Text("\(item.price)") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "909090")) + } + } else { + Text("무료") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "909090")) + } + } + + Rectangle() + .frame(height: 0.5) + .foregroundColor(Color(hex: "595959")) + } + .frame(maxWidth: .infinity) + } +} + +struct ContentListItemView_Previews: PreviewProvider { + static var previews: some View { + ContentListItemView( + item: GetAudioContentListItem( + contentId: 25, + coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", + title: "폭우", + price: 110, + themeStr: "test", + duration: "00:04:43", + likeCount: 2, + commentCount: 0, + isAdult: false + ) + ) + } +} diff --git a/SodaLive/Sources/Content/Main/ContentMainBannerView.swift b/SodaLive/Sources/Content/Main/ContentMainBannerView.swift index 4034716..f8d0624 100644 --- a/SodaLive/Sources/Content/Main/ContentMainBannerView.swift +++ b/SodaLive/Sources/Content/Main/ContentMainBannerView.swift @@ -32,7 +32,7 @@ struct ContentMainBannerView: View { case .EVENT: AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!)) case .CREATOR: - break + AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!)) 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) @@ -53,7 +53,7 @@ struct ContentMainBannerView: View { case .EVENT: AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!)) case .CREATOR: - break + AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!)) 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) diff --git a/SodaLive/Sources/Content/Main/ContentMainItemView.swift b/SodaLive/Sources/Content/Main/ContentMainItemView.swift index 223e9a1..9bb0f46 100644 --- a/SodaLive/Sources/Content/Main/ContentMainItemView.swift +++ b/SodaLive/Sources/Content/Main/ContentMainItemView.swift @@ -47,7 +47,7 @@ struct ContentMainItemView: View { .scaledToFill() .frame(width: 21.3, height: 21.3) .clipShape(Circle()) - .onTapGesture { } + .onTapGesture { AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId)) } Text(item.creatorNickname) .font(.custom(Font.medium.rawValue, size: 12)) diff --git a/SodaLive/Sources/Content/Main/ContentMainNewContentCreatorItemView.swift b/SodaLive/Sources/Content/Main/ContentMainNewContentCreatorItemView.swift index d9862dc..ebca9cd 100644 --- a/SodaLive/Sources/Content/Main/ContentMainNewContentCreatorItemView.swift +++ b/SodaLive/Sources/Content/Main/ContentMainNewContentCreatorItemView.swift @@ -26,7 +26,9 @@ struct ContentMainNewContentCreatorItemView: View { .frame(width: screenSize().width * 0.18) .lineLimit(1) } - .onTapGesture {} + .onTapGesture { + AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId)) + } } } diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 51b0ba9..c5225d2 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -86,6 +86,18 @@ struct ContentView: View { case .createContent: ContentCreateView() + case .liveReservationComplete(let response): + LiveReservationCompleteView(reservationCompleteData: response) + + case .creatorDetail(let userId): + UserProfileView(userId: userId) + + case .followerList(let userId): + FollowerListView(userId: userId) + + case .creatorNoticeWrite(let notice): + CreatorNoticeWriteView(notice: notice) + default: EmptyView() .frame(width: 0, height: 0, alignment: .topLeading) diff --git a/SodaLive/Sources/Explorer/ExplorerApi.swift b/SodaLive/Sources/Explorer/ExplorerApi.swift index 4a19fe8..40c4904 100644 --- a/SodaLive/Sources/Explorer/ExplorerApi.swift +++ b/SodaLive/Sources/Explorer/ExplorerApi.swift @@ -11,6 +11,12 @@ import Moya enum ExplorerApi { case getExplorer case searchChannel(channel: String) + case getCreatorProfile(userId: Int) + case getFollowerList(userId: Int, page: Int, size: Int) + case getCreatorProfileCheers(userId: Int, page: Int, size: Int) + case writeCheers(parentCheersId: Int?, creatorId: Int, content: String) + case modifyCheers(cheersId: Int, content: String) + case writeCreatorNotice(request: PostCreatorNoticeRequest) } extension ExplorerApi: TargetType { @@ -25,13 +31,37 @@ extension ExplorerApi: TargetType { case .searchChannel: return "/explorer/search/channel" + + case .getCreatorProfile(let userId): + return "/explorer/profile/\(userId)" + + case .getFollowerList(let userId, _, _): + return "/explorer/profile/\(userId)/follower-list" + + case .getCreatorProfileCheers(let userId, _, _): + return "/explorer/profile/\(userId)/cheers" + + case .writeCheers: + return "/explorer/profile/cheers" + + case .modifyCheers: + return "/explorer/profile/cheers" + + case .writeCreatorNotice: + return "/explorer/profile/notice" } } var method: Moya.Method { switch self { - case .getExplorer, .searchChannel: + case .getExplorer, .searchChannel, .getCreatorProfile, .getFollowerList, .getCreatorProfileCheers: return .get + + case .writeCheers, .writeCreatorNotice: + return .post + + case .modifyCheers: + return .put } } @@ -42,6 +72,38 @@ extension ExplorerApi: TargetType { case .searchChannel(let channel): return .requestParameters(parameters: ["channel" : channel], encoding: URLEncoding.queryString) + + case .getCreatorProfile: + let parameters = ["timezone": TimeZone.current.identifier] as [String: Any] + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + + case .getFollowerList(_, let page, let size): + let parameters = [ + "page": page - 1, + "size": size + ] as [String : Any] + + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + + case .getCreatorProfileCheers(_, 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 .writeCheers(let parentCheersId, let creatorId, let content): + let request = PostWriteCheersRequest(parentId: parentCheersId, creatorId: creatorId, content: content) + return .requestJSONEncodable(request) + + case .modifyCheers(let cheersId, let content): + let request = PutModifyCheersRequest(cheersId: cheersId, content: content) + return .requestJSONEncodable(request) + + case .writeCreatorNotice(let request): + return .requestJSONEncodable(request) } } diff --git a/SodaLive/Sources/Explorer/ExplorerRepository.swift b/SodaLive/Sources/Explorer/ExplorerRepository.swift index 137edd4..a9ceded 100644 --- a/SodaLive/Sources/Explorer/ExplorerRepository.swift +++ b/SodaLive/Sources/Explorer/ExplorerRepository.swift @@ -20,4 +20,28 @@ final class ExplorerRepository { func searchChannel(channel: String) -> AnyPublisher { return api.requestPublisher(.searchChannel(channel: channel)) } + + func getCreatorProfile(id: Int) -> AnyPublisher { + return api.requestPublisher(.getCreatorProfile(userId: id)) + } + + func getFollowerList(userId: Int, page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getFollowerList(userId: userId, page: page, size: size)) + } + + func getCreatorProfileCheers(userId: Int, page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getCreatorProfileCheers(userId: userId, page: page, size: size)) + } + + func writeCheers(parentCheersId: Int?, creatorId: Int, content: String) -> AnyPublisher { + return api.requestPublisher(.writeCheers(parentCheersId: parentCheersId, creatorId: creatorId, content: content)) + } + + func modifyCheers(cheersId: Int, content: String) -> AnyPublisher { + return api.requestPublisher(.modifyCheers(cheersId: cheersId, content: content)) + } + + func writeCreatorNotice(notice: String) -> AnyPublisher { + return api.requestPublisher(.writeCreatorNotice(request: PostCreatorNoticeRequest(notice: notice))) + } } diff --git a/SodaLive/Sources/Explorer/ExplorerView.swift b/SodaLive/Sources/Explorer/ExplorerView.swift index 48d5d15..d4c1861 100644 --- a/SodaLive/Sources/Explorer/ExplorerView.swift +++ b/SodaLive/Sources/Explorer/ExplorerView.swift @@ -68,7 +68,7 @@ struct ExplorerView: View { } .frame(width: screenSize().width - 26.7) .contentShape(Rectangle()) - .onTapGesture {} + .onTapGesture { AppState.shared.setAppStep(step: .creatorDetail(userId: channel.id)) } } } } else { @@ -136,7 +136,10 @@ struct ExplorerView: View { .padding(.top, 3.3) } .contentShape(Rectangle()) - .onTapGesture {} + .onTapGesture { + AppState.shared + .setAppStep(step: .creatorDetail(userId: creator.id)) + } } } } diff --git a/SodaLive/Sources/Explorer/Profile/CreatorNoticeWriteView.swift b/SodaLive/Sources/Explorer/Profile/CreatorNoticeWriteView.swift new file mode 100644 index 0000000..fd2d478 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/CreatorNoticeWriteView.swift @@ -0,0 +1,72 @@ +// +// CreatorNoticeWriteView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import Combine + +struct CreatorNoticeWriteView: View { + + let notice: String + + @ObservedObject var viewModel = CreatorNoticeWriteViewModel() + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + DetailNavigationBar(title: "공지사항 쓰기") + + TextViewWrapper( + text: $viewModel.writeNotice, + placeholder: viewModel.placeholder, + textColorHex: "eeeeee", + backgroundColorHex: "222222", + notice: notice + ) + .frame(width: screenSize().width - 26.7, height: 300) + .padding(.top, 13.3) + + Text("저장") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "ffffff")) + .padding(.vertical, 11.7) + .frame(width: screenSize().width - 26.7) + .background(Color(hex: "9970ff")) + .cornerRadius(5.3) + .padding(.top, 20) + .onTapGesture { + hideKeyboard() + viewModel.writeCreatorNotice() + } + + Spacer() + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) { + 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(.leading) + .cornerRadius(20) + .padding(.bottom, 66.7) + Spacer() + } + } + .onTapGesture { + hideKeyboard() + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + viewModel.writeNotice = notice + } + } + } +} diff --git a/SodaLive/Sources/Explorer/Profile/CreatorNoticeWriteViewModel.swift b/SodaLive/Sources/Explorer/Profile/CreatorNoticeWriteViewModel.swift new file mode 100644 index 0000000..0ebbbd9 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/CreatorNoticeWriteViewModel.swift @@ -0,0 +1,63 @@ +// +// CreatorNoticeWriteViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation +import Combine + +final class CreatorNoticeWriteViewModel: ObservableObject { + let repository = ExplorerRepository() + private var subscription = Set() + + @Published var isLoading = false + @Published var errorMessage = "" + @Published var isShowPopup = false + + @Published var writeNotice = "" + + var placeholder = "공지사항을 입력해 주세요" + + func writeCreatorNotice() { + isLoading = true + + repository.writeCreatorNotice(notice: writeNotice.trimmingCharacters(in: .whitespacesAndNewlines) != placeholder ? writeNotice : "") + .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(ApiResponseWithoutData.self, from: responseData) + + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + + if decoded.success { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + AppState.shared.back() + } + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + + self.isLoading = false + } + .store(in: &subscription) + } +} diff --git a/SodaLive/Sources/Explorer/Profile/FanTalk/PostWriteCheersRequest.swift b/SodaLive/Sources/Explorer/Profile/FanTalk/PostWriteCheersRequest.swift new file mode 100644 index 0000000..ad95fb6 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/FanTalk/PostWriteCheersRequest.swift @@ -0,0 +1,19 @@ +// +// PostWriteCheersRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct PostWriteCheersRequest: Encodable { + let parentId: Int? + let creatorId: Int + let content: String +} + +struct PutModifyCheersRequest: Encodable { + let cheersId: Int + let content: String +} diff --git a/SodaLive/Sources/Explorer/Profile/FanTalk/UserProfileFanTalkCheersItemView.swift b/SodaLive/Sources/Explorer/Profile/FanTalk/UserProfileFanTalkCheersItemView.swift new file mode 100644 index 0000000..3c5454c --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/FanTalk/UserProfileFanTalkCheersItemView.swift @@ -0,0 +1,137 @@ +// +// UserProfileFanTalkCheersItemView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import Kingfisher + +struct UserProfileFanTalkCheersItemView: View { + + let userId: Int + let cheer: GetCheersResponseItem + let writeCheerReply: (String) -> Void + let modifyCheer: (Int, String) -> Void + let reportPopup: (Int) -> Void + + @State var replyContent: String = "" + @State var isShowInputReply = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 6.7) { + KFImage(URL(string: cheer.profileUrl)) + .cancelOnDisappear(true) + .downsampling(size: CGSize(width: 33.3, height: 33.3)) + .resizable() + .frame(width: 33.3, height: 33.3) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 0) { + Text("\(cheer.nickname)") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Text("\(cheer.date)") + .font(.custom(Font.medium.rawValue, size: 10.7)) + .foregroundColor(Color(hex: "525252")) + .padding(.top, 8.3) + + Text("\(cheer.content)") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + .padding(.top, 13.3) + + if isShowInputReply { + HStack(spacing: 10) { + TextField("응원댓글에 답글을 남겨보세요!", text: $replyContent) + .autocapitalization(.none) + .disableAutocorrection(true) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(13.3) + .background(Color(hex: "232323")) + .accentColor(Color(hex: "9970ff")) + .keyboardType(.default) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(lineWidth: 1) + .foregroundColor(Color(hex: "9970ff")) + ) + + Text("등록") + .font(.custom(Font.bold.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "ffffff")) + .padding(13.3) + .background(Color(hex: "9970ff")) + .cornerRadius(6.7) + .onTapGesture { + if cheer.replyList.count > 0 { + modifyCheer(cheer.replyList[0].cheersId, replyContent) + } else { + writeCheerReply(replyContent) + } + } + } + .padding(.top, 10) + } else { + if cheer.replyList.count <= 0 { + if userId == UserDefaults.int(forKey: .userId) { + Text("답글쓰기") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "9970ff")) + .padding(.top, 18.3) + .onTapGesture { + isShowInputReply = true + } + } + } else { + let reply = cheer.replyList[0] + VStack(alignment: .leading, spacing: 8.3) { + Text(reply.content) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "ffffff")) + .frame(minWidth: 100) + .padding(.horizontal, 6.7) + .padding(.vertical, 6.7) + .background(Color(hex: "9970ff").opacity(0.3)) + .cornerRadius(16.7) + .padding(.top, 18.3) + + HStack(spacing: 6.7) { + Text(reply.date) + .font(.custom(Font.medium.rawValue, size: 10.7)) + .foregroundColor(Color(hex: "525252")) + + if userId == UserDefaults.int(forKey: .userId) { + Text("답글 수정") + .font(.custom(Font.medium.rawValue, size: 10.7)) + .foregroundColor(Color(hex: "9970ff")) + .onTapGesture { + self.replyContent = reply.content + isShowInputReply = true + } + } + } + } + } + } + } + + Spacer() + + Image("ic_seemore_vertical") + .onTapGesture { reportPopup(cheer.cheersId) } + } + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.5)) + .padding(.top, 13.3) + } + .frame(width: screenSize().width - 26.7) + } +} diff --git a/SodaLive/Sources/Explorer/Profile/FanTalk/UserProfileFanTalkView.swift b/SodaLive/Sources/Explorer/Profile/FanTalk/UserProfileFanTalkView.swift new file mode 100644 index 0000000..6a4d170 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/FanTalk/UserProfileFanTalkView.swift @@ -0,0 +1,143 @@ +// +// UserProfileFanTalkView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import Kingfisher + +struct UserProfileFanTalkView: View { + + @StateObject var viewModel = UserProfileFanTalkViewModel() + + let userId: Int + let cheers: GetCheersResponse + let errorPopup: (String) -> Void + let reportPopup: (Int) -> Void + + @Binding var isLoading: Bool + @State private var cheersContent: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + Text("팬 Talk") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Text("전체보기") + .font(.custom(Font.light.rawValue, size: 11.3)) + .foregroundColor(Color(hex: "bbbbbb")) + .onTapGesture { + AppState.shared.setAppStep(step: .userProfileFanTalkAll(userId: userId)) + } + } + .padding(.horizontal, 13.3) + + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6.7) { + Text("응원") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Text("\(cheers.totalCount)") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + } + .padding(.top, 20) + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.5)) + .padding(.top, 13.3) + + HStack(spacing: 0) { + TextField("응원댓글을 입력하세요", text: $cheersContent) + .autocapitalization(.none) + .disableAutocorrection(true) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + .accentColor(Color(hex: "9970ff")) + .keyboardType(.default) + .padding(.horizontal, 13.3) + + Spacer() + + Image("btn_message_send") + .resizable() + .frame(width: 35, height: 35) + .padding(6.7) + .onTapGesture { + hideKeyboard() + viewModel.writeCheers(creatorId: userId, cheersContent: cheersContent) + cheersContent = "" + } + } + .background(Color(hex: "232323")) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(lineWidth: 1) + .foregroundColor(Color(hex: "9970ff")) + ) + .padding(.top, 13.3) + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.5)) + .padding(.top, 13.3) + + VStack(spacing: 20) { + if viewModel.cheersTotalCount > 0 { + ForEach(0.. Void)? + var setLoading: ((Bool) -> Void)? + + private var repository = ExplorerRepository() + private let reportRepository = ReportRepository() + private var subscription = Set() + + var cheersPage = 1 + var pageSize = 10 + + private var isCheersLast = false + + func getCheersList(creatorId: Int) { + if !isCheersLast && !isLoading { + if let setLoading = self.setLoading { + setLoading(true) + } + isLoading = true + + repository.getCreatorProfileCheers(userId: creatorId, page: cheersPage, 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 + if let setLoading = self.setLoading { + setLoading(false) + } + self.isLoading = false + 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 !data.cheers.isEmpty { + cheersPage += 1 + self.cheersTotalCount = data.totalCount + self.cheersList.append(contentsOf: data.cheers) + } else { + isCheersLast = true + } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + if let errorPopup = errorPopup { + errorPopup(self.errorMessage) + } else { + self.isShowPopup = true + } + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + if let errorPopup = errorPopup { + errorPopup(self.errorMessage) + } else { + self.isShowPopup = true + } + } + } + .store(in: &subscription) + } + } + + func writeCheersReply(parentCheersId: Int, creatorId: Int, cheersReplyContent: String) { + if cheersReplyContent.trimmingCharacters(in: .whitespaces).isEmpty { + if let errorPopup = errorPopup { + errorPopup("내용을 입력하세요") + } else { + errorMessage = "내용을 입력하세요" + isShowPopup = true + } + + return + } + + if let setLoading = self.setLoading { + setLoading(true) + } + isLoading = true + + repository.writeCheers(parentCheersId: parentCheersId, creatorId: creatorId, content: cheersReplyContent) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + if let setLoading = self.setLoading { + setLoading(false) + } + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + cheersPage = 1 + isCheersLast = false + cheersList.removeAll() + self.getCheersList(creatorId: creatorId) + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + if let errorPopup = errorPopup { + errorPopup(self.errorMessage) + } else { + self.isShowPopup = true + } + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + if let errorPopup = errorPopup { + errorPopup(self.errorMessage) + } else { + self.isShowPopup = true + } + } + } + .store(in: &subscription) + } + + func writeCheers(creatorId: Int, cheersContent: String) { + if cheersContent.trimmingCharacters(in: .whitespaces).isEmpty { + if let errorPopup = errorPopup { + errorPopup("내용을 입력하세요") + } else { + errorMessage = "내용을 입력하세요" + isShowPopup = true + } + + return + } + + if let setLoading = self.setLoading { + setLoading(true) + } + isLoading = true + + repository.writeCheers(parentCheersId: nil, creatorId: creatorId, content: cheersContent) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + if let setLoading = self.setLoading { + setLoading(false) + } + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + cheersPage = 1 + isCheersLast = false + cheersList.removeAll() + self.getCheersList(creatorId: creatorId) + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + if let errorPopup = errorPopup { + errorPopup(self.errorMessage) + } else { + self.isShowPopup = true + } + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + if let errorPopup = errorPopup { + errorPopup(self.errorMessage) + } else { + self.isShowPopup = true + } + } + } + .store(in: &subscription) + } + + func modifyCheersReply(cheersId: Int, creatorId: Int, cheersReplyContent: String) { + if cheersReplyContent.trimmingCharacters(in: .whitespaces).isEmpty { + if let errorPopup = errorPopup { + errorPopup("내용을 입력하세요") + } else { + errorMessage = "내용을 입력하세요" + isShowPopup = true + } + + return + } + + if let setLoading = self.setLoading { + setLoading(true) + } + isLoading = true + + repository.modifyCheers(cheersId: cheersId, content: cheersReplyContent) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + if let setLoading = self.setLoading { + setLoading(false) + } + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + cheersPage = 1 + isCheersLast = false + cheersList.removeAll() + self.getCheersList(creatorId: creatorId) + } 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 report(type: ReportType, reason: String = "프로필 신고") { + isLoading = true + + let request = ReportRequest(type: type, reason: reason, reportedMemberId: nil, cheersId: reportCheersId > 0 && type == .CHEERS ? reportCheersId : nil, audioContentId: nil) + reportRepository.report(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 + + self.reportCheersId = 0 + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + 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/Explorer/Profile/FollowerList/FollowerListItemView.swift b/SodaLive/Sources/Explorer/Profile/FollowerList/FollowerListItemView.swift new file mode 100644 index 0000000..118e011 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/FollowerList/FollowerListItemView.swift @@ -0,0 +1,65 @@ +// +// FollowerListItemView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import Kingfisher + +struct FollowerListItemView: View { + + let item: GetFollowerListResponseItem + let creatorFollow: (Int) -> Void + let creatorUnFollow: (Int) -> Void + + var body: some View { + VStack(spacing: 13.3) { + HStack(spacing: 0) { + KFImage(URL(string: item.profileImage)) + .resizable() + .frame(width: 60, height: 60) + .clipShape(Circle()) + + Text(item.nickname) + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.leading, 13.3) + + Spacer() + + if let isFollow = item.isFollow { + Image(isFollow ? "btn_notification_selected" : "btn_notification") + .onTapGesture { + isFollow ? + creatorUnFollow(item.userId) : + creatorFollow(item.userId) + } + } + } + .padding(.top, 13.3) + + Rectangle() + .frame(height: 1) + .frame(maxWidth: .infinity) + .foregroundColor(Color(hex: "909090")) + } + .padding(.horizontal, 20) + } +} + +struct FollowerListItemView_Previews: PreviewProvider { + static var previews: some View { + FollowerListItemView( + item: GetFollowerListResponseItem( + userId: 1, + profileImage: "https://test-cf.sodalive.net/profile/default-profile.png", + nickname: "상남자", + isFollow: false + ), + creatorFollow: { _ in }, + creatorUnFollow: { _ in } + ) + } +} diff --git a/SodaLive/Sources/Explorer/Profile/FollowerList/FollowerListView.swift b/SodaLive/Sources/Explorer/Profile/FollowerList/FollowerListView.swift new file mode 100644 index 0000000..e2da3fb --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/FollowerList/FollowerListView.swift @@ -0,0 +1,85 @@ +// +// FollowerListView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI + +struct FollowerListView: View { + + let userId: Int + @StateObject var viewModel = FollowerListViewModel() + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + DetailNavigationBar(title: "팔로워 리스트") + + HStack(spacing: 4) { + Text("전체") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Text("\(viewModel.totalCount)") + .font(.custom(Font.medium.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "9970ff")) + + Spacer() + } + .padding(.top, 26.7) + .padding(.horizontal, 13.3) + + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 0) { + ForEach(0..() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var totalCount = 0 + @Published var followerListItems = [GetFollowerListResponseItem]() + + var userId: Int = 0 + var page = 1 + var isLast = false + private let pageSize = 10 + + func getFollowerList() { + if page == 1 { + followerListItems.removeAll() + } + + if (!isLast && !isLoading) { + isLoading = true + + repository.getFollowerList(userId: userId, 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.self, from: responseData) + + if let data = decoded.data, decoded.success { + if !data.items.isEmpty { + page += 1 + self.totalCount = data.totalCount + self.followerListItems.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 + } + } + .store(in: &subscription) + } + } + + func creatorFollow(userId: Int) { + isLoading = true + + userRepository.creatorFollow(creatorId: userId) + .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.isLast = false + self.page = 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.getFollowerList() + } + } 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 creatorUnFollow(userId: Int) { + isLoading = true + + userRepository.creatorUnFollow(creatorId: userId) + .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.isLast = false + self.page = 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.getFollowerList() + } + } 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/Explorer/Profile/FollowerList/GetFollowerListResponse.swift b/SodaLive/Sources/Explorer/Profile/FollowerList/GetFollowerListResponse.swift new file mode 100644 index 0000000..2162522 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/FollowerList/GetFollowerListResponse.swift @@ -0,0 +1,20 @@ +// +// GetFollowerListResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct GetFollowerListResponse: Decodable { + let totalCount: Int + let items: [GetFollowerListResponseItem] +} + +struct GetFollowerListResponseItem: Decodable { + let userId: Int + let profileImage: String + let nickname: String + let isFollow: Bool? +} diff --git a/SodaLive/Sources/Explorer/Profile/GetCheersResponse.swift b/SodaLive/Sources/Explorer/Profile/GetCheersResponse.swift new file mode 100644 index 0000000..29edc9b --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/GetCheersResponse.swift @@ -0,0 +1,23 @@ +// +// GetCheersResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct GetCheersResponse: Decodable { + let totalCount: Int + let cheers: [GetCheersResponseItem] +} + +struct GetCheersResponseItem: Decodable { + let cheersId: Int + let nickname: String + let profileUrl: String + let content: String + let date: String + let replyList: [GetCheersResponseItem] +} + diff --git a/SodaLive/Sources/Explorer/Profile/GetCreatorProfileResponse.swift b/SodaLive/Sources/Explorer/Profile/GetCreatorProfileResponse.swift index 48c1df5..f836c2b 100644 --- a/SodaLive/Sources/Explorer/Profile/GetCreatorProfileResponse.swift +++ b/SodaLive/Sources/Explorer/Profile/GetCreatorProfileResponse.swift @@ -7,6 +7,60 @@ import Foundation +struct GetCreatorProfileResponse: Decodable { + let creator: CreatorResponse + let userDonationRanking: [UserDonationRankingResponse] + let similarCreatorList: [SimilarCreatorResponse] + let liveRoomList: [LiveRoomResponse] + let notice: String + let cheers: GetCheersResponse + let activitySummary: GetCreatorActivitySummary + let isBlock: Bool +} + +struct CreatorResponse: Decodable { + let creatorId: Int + let profileUrl: String + let nickname: String + let tags: [String] + let introduce: String + let instagramUrl: String? + let youtubeUrl: String? + let websiteUrl: String? + let blogUrl: String? + let isNotification: Bool + let notificationRecipientCount: Int +} + +struct UserDonationRankingResponse: Decodable { + let userId: Int + let nickname: String + let profileImage: String + let donationCoin: Int? +} + +struct SimilarCreatorResponse: Decodable { + let userId: Int + let nickname: String + let profileImage: String + let tags: [String] +} + +struct LiveRoomResponse: Decodable { + let roomId: Int + let title: String + let content: String + let isPaid: Bool + let beginDateTime: String + let coverImageUrl: String + let isAdult: Bool + let price: Int + let channelName: String? + let managerNickname: String + let isReservation: Bool + let isActive: Bool +} + struct GetAudioContentListResponse: Decodable { let totalCount: Int let items: [GetAudioContentListItem] @@ -23,3 +77,10 @@ struct GetAudioContentListItem: Decodable { let commentCount: Int let isAdult: Bool } + +struct GetCreatorActivitySummary: Decodable { + let liveCount: Int + let liveTime: Int + let liveContributorCount: Int + let contentCount: Int +} diff --git a/SodaLive/Sources/Explorer/Profile/MemberBlockRequest.swift b/SodaLive/Sources/Explorer/Profile/MemberBlockRequest.swift new file mode 100644 index 0000000..73477f6 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/MemberBlockRequest.swift @@ -0,0 +1,12 @@ +// +// MemberBlockRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct MemberBlockRequest: Encodable { + let blockMemberId: Int +} diff --git a/SodaLive/Sources/Explorer/Profile/PostCreatorNoticeRequest.swift b/SodaLive/Sources/Explorer/Profile/PostCreatorNoticeRequest.swift new file mode 100644 index 0000000..5d094f1 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/PostCreatorNoticeRequest.swift @@ -0,0 +1,12 @@ +// +// PostCreatorNoticeRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct PostCreatorNoticeRequest: Encodable { + let notice: String +} diff --git a/SodaLive/Sources/Explorer/Profile/UserProfileActivitySummaryView.swift b/SodaLive/Sources/Explorer/Profile/UserProfileActivitySummaryView.swift new file mode 100644 index 0000000..f8c00a9 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/UserProfileActivitySummaryView.swift @@ -0,0 +1,88 @@ +// +// UserProfileActivitySummaryView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI + +struct UserProfileActivitySummaryView: View { + + let item: GetCreatorActivitySummary + + var body: some View { + HStack(spacing: 0) { + ActivitySummaryItemView( + title: "라이브\n횟수", + count: String(format: "%d", item.liveCount) + ) + + ActivitySummaryDividerView() + + ActivitySummaryItemView( + title: "라이브\n시간", + count: String(format: "%d", item.liveTime) + ) + + ActivitySummaryDividerView() + + ActivitySummaryItemView( + title: "라이브\n참여자", + count: String(format: "%d", item.liveContributorCount) + ) + + ActivitySummaryDividerView() + + ActivitySummaryItemView( + title: "등록\n콘텐츠", + count: String(format: "%d", item.contentCount) + ) + } + .padding(.vertical, 13.3) + .background(Color(hex: "222222")) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(hex: "9970ff"), lineWidth: 1) + ) + } + + @ViewBuilder + func ActivitySummaryItemView(title: String, count: String) -> some View { + HStack(spacing: 0) { + Spacer() + VStack(spacing: 8) { + Text(title) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "909090")) + .multilineTextAlignment(.center) + + Text(count) + .font(.custom(Font.bold.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + Spacer() + } + } + + @ViewBuilder + func ActivitySummaryDividerView() -> some View { + Rectangle() + .frame(width: 1, height: 33.3) + .foregroundColor(Color(hex: "9970ff")) + } +} + +struct UserProfileActivitySummaryView_Previews: PreviewProvider { + static var previews: some View { + UserProfileActivitySummaryView( + item: GetCreatorActivitySummary( + liveCount: 1000, + liveTime: 1000, + liveContributorCount: 5000, + contentCount: 30 + ) + ) + } +} diff --git a/SodaLive/Sources/Explorer/Profile/UserProfileContentView.swift b/SodaLive/Sources/Explorer/Profile/UserProfileContentView.swift new file mode 100644 index 0000000..d9de2ee --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/UserProfileContentView.swift @@ -0,0 +1,74 @@ +// +// UserProfileContentView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI + +struct UserProfileContentView: View { + + let userId: Int + let items: [GetAudioContentListItem] + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text(userId == UserDefaults.int(forKey: .userId) ? "내 콘텐츠" : "콘텐츠") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Text("전체보기") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "bbbbbb")) + .onTapGesture {} + } + + if userId == UserDefaults.int(forKey: .userId) { + Text("새로운 콘텐츠 등록하기") + .font(.custom(Font.bold.rawValue, size: 15)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.vertical, 17) + .frame(maxWidth: .infinity) + .background(Color(hex: "9970ff")) + .cornerRadius(5.3) + .padding(.top, 21) + .onTapGesture { AppState.shared.setAppStep(step: .createContent) } + } + + VStack(spacing: 10.7) { + ForEach(0.. Void + let creatorUnFollow: () -> Void + let shareChannel: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 20) { + KFImage(URL(string: creator.profileUrl)) + .resizable() + .scaledToFill() + .frame(width: 90, height: 90) + .background(Color(hex: "3e3358")) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + Text(creator.nickname) + .font(.custom(Font.bold.rawValue, size: 20)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.top, 6.7) + + Spacer() + + Image("btn_big_share") + .resizable() + .frame(width: 33.3, height: 33.3) + .onTapGesture { shareChannel() } + } + + if creator.creatorId == UserDefaults.int(forKey: .userId) { + Text("팔로워 리스트") + .font(.custom(Font.bold.rawValue, size: 12)) + .foregroundColor(Color.white) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .overlay( + RoundedRectangle(cornerRadius: 16.7) + .stroke(Color(hex: "9970ff"), lineWidth: 1) + ) + .padding(.top, 13.3) + .onTapGesture { + AppState.shared.setAppStep(step: .followerList(userId: creator.creatorId)) + } + } else { + VStack(alignment: .leading, spacing: 9.3) { + Image(creator.isNotification ? "btn_notification_selected" : "btn_notification") + .resizable() + .frame(width: 83.3, height: 26.7) + .onTapGesture { + if creator.isNotification { + creatorUnFollow() + } else { + creatorFollow() + } + } + + Text("팔로워 \(creator.notificationRecipientCount)명") + .font(.custom(Font.light.rawValue, size: 12)) + .foregroundColor(Color(hex: "bbbbbb")) + } + .padding(.top, 13.3) + } + } + } + + Text(creator.tags.map { "#\($0)" }.joined(separator: " ")) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "9970ff")) + .padding(.top, creator.tags.count > 0 ? 13.3 : 0) + + HStack(spacing: 10) { + Spacer() + + if let websiteUrl = creator.websiteUrl, websiteUrl.count > 0, let url = URL(string: websiteUrl), UIApplication.shared.canOpenURL(url) { + Button(action: {UIApplication.shared.open(url)}) { + Image("ic_website_circle") + .resizable() + .frame(width: 40, height: 40) + } + .padding(.top, 13.3) + } + + if let blogUrl = creator.blogUrl, blogUrl.count > 0, let url = URL(string: blogUrl), UIApplication.shared.canOpenURL(url) { + Button(action: {UIApplication.shared.open(url)}) { + Image("ic_blog_circle") + .resizable() + .frame(width: 40, height: 40) + } + .padding(.top, 13.3) + } + + if let instagramUrl = creator.instagramUrl, instagramUrl.count > 0, let url = URL(string: instagramUrl), UIApplication.shared.canOpenURL(url) { + Button(action: {UIApplication.shared.open(url)}) { + Image("ic_instagram_circle") + .resizable() + .frame(width: 40, height: 40) + } + .padding(.top, 13.3) + } + + if let youtubeUrl = creator.youtubeUrl, youtubeUrl.count > 0, let url = URL(string: youtubeUrl), UIApplication.shared.canOpenURL(url) { + Button(action: {UIApplication.shared.open(url)}) { + Image("ic_youtube_circle") + .resizable() + .frame(width: 40, height: 40) + } + .padding(.top, 13.3) + } + } + } + .padding(20) + .background(Color(hex: "222222")) + .cornerRadius(16.7) + .padding(.top, 20) + .padding(.horizontal, 13.3) + } +} + +struct UserProfileCreatorView_Previews: PreviewProvider { + static var previews: some View { + UserProfileCreatorView( + creator: CreatorResponse( + creatorId: 2, + profileUrl: "https://test-cf.sodalive.net/profile/default-profile.png", + nickname: "수다친구1", + tags: ["썸", "연애", "부부"], + introduce: "상담사1 입니다.yyyyyyy\n\n\n\n\n\n\njgdgjdgjdgicyifyicyi\n\n\n\n\n\n\n\n\n\n\n\n", + instagramUrl: Optional("3x2tfZnfLRo"), + youtubeUrl: Optional("https://www.youtube.com/watch?v=3x2tfZnfLRo"), + websiteUrl: Optional("https://instagram.com/dear.zia"), + blogUrl: Optional("dear.zia"), + isNotification: false, + notificationRecipientCount: 2 + ) + ) { + } creatorUnFollow: { + } shareChannel: { + } + } +} diff --git a/SodaLive/Sources/Explorer/Profile/UserProfileDonationView.swift b/SodaLive/Sources/Explorer/Profile/UserProfileDonationView.swift new file mode 100644 index 0000000..6bae502 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/UserProfileDonationView.swift @@ -0,0 +1,82 @@ +// +// UserProfileDonationView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import Kingfisher + +struct UserProfileDonationView: View { + + let userId: Int + let donationRankingResponse: [UserDonationRankingResponse] + let rankingCrawns = ["ic_crown_1", "ic_crown_2", "ic_crown_3"] + let rankingColors = [ + [Color(hex: "ffdc00"), Color(hex: "ffb600")], + [Color(hex: "ffffff"), Color(hex: "9f9f9f")], + [Color(hex: "e6a77a"), Color(hex: "c67e4a")], + [Color(hex: "ffffff").opacity(0), Color(hex: "ffffff").opacity(0)] + ] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + Text("후원랭킹") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Text("전체보기") + .font(.custom(Font.light.rawValue, size: 11.3)) + .foregroundColor(Color(hex: "bbbbbb")) + .onTapGesture {} + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 13.3) { + ForEach(0.. Void + let onClickReservation: (LiveRoomResponse) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 26.7) { + + HStack(spacing: 0) { + Text("라이브") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + } + + VStack(spacing: 13.3) { + ForEach(0.. 0 ? "\(liveRoom.price)코인으로 " : "")예약하기") + .font(.custom(Font.bold.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "000000")) + .frame( + width: screenSize().width - 26.7 - 100, + height: 36.7 + ) + .background(Color(hex: "fdca2f")) + .cornerRadius(5.3) + .onTapGesture { + onClickReservation(liveRoom) + } + } + } + } else { + Text("다시듣기를 지원하지 않습니다") + .font(.custom(Font.bold.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "777777")) + .frame( + width: screenSize().width - 26.7 - 100, + height: 36.7 + ) + .background(Color(hex: "525252")) + .cornerRadius(5.3) + } + } + } + .frame(height: 116.7) + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.5)) + } + } + } + } + } +} diff --git a/SodaLive/Sources/Explorer/Profile/UserProfileSimilarCreatorView.swift b/SodaLive/Sources/Explorer/Profile/UserProfileSimilarCreatorView.swift new file mode 100644 index 0000000..ac973ac --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/UserProfileSimilarCreatorView.swift @@ -0,0 +1,55 @@ +// +// UserProfileSimilarCreatorView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import Kingfisher + +struct UserProfileSimilarCreatorView: View { + + let creators: [SimilarCreatorResponse] + let onClickCreator: (Int) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 26.7) { + Text("함께 들으면 좋은 채널") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + + VStack(spacing: 10) { + ForEach(0.. 0 { + UserProfileLiveView( + userId: userId, + liveRoomList: creatorProfile.liveRoomList, + onClickParticipant: { liveRoom in + if creatorProfile.creator.creatorId == UserDefaults.int(forKey: .userId) { + viewModel.errorMessage = "현재 라이브 중입니다." + viewModel.isShowPopup = true + } else { + AppState.shared.isShowPlayer = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + viewModel.enterLiveRoom(roomId: liveRoom.roomId) + } + } + }, + onClickReservation: { liveRoom in + if creatorProfile.creator.creatorId == UserDefaults.int(forKey: .userId) { + viewModel.errorMessage = "내가 만든 라이브는 예약할 수 없습니다." + viewModel.isShowPopup = true + } else { + viewModel.reservationLiveRoom(roomId: liveRoom.roomId) + } + } + ) + .padding(.top, 46.7) + .padding(.horizontal, 13.3) + } + + VStack(spacing: 26.7) { + let introduce = creatorProfile.creator.introduce + UserProfileIntroduceView( + introduce: introduce.trimmingCharacters(in: .whitespaces).count <= 0 ? + "채널 소개내용이 없습니다." : + introduce) + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.5)) + .padding(.horizontal, 13.3) + } + .padding(.top, 26.7) + + if creatorProfile.userDonationRanking.count > 0 { + VStack(spacing: 26.7) { + UserProfileDonationView(userId: userId, donationRankingResponse: creatorProfile.userDonationRanking) + .padding(.horizontal, 13.3) + + Rectangle() + .frame(height: 6.7) + .foregroundColor(Color(hex: "909090").opacity(0.5)) + } + .padding(.top, 26.7) + } + + VStack(spacing: 26.7) { + UserProfileSimilarCreatorView( + creators: creatorProfile.similarCreatorList, + onClickCreator: { viewModel.getCreatorProfile(userId: $0) } + ) + .padding(.horizontal, 13.3) + + Rectangle() + .frame(height: 6.7) + .foregroundColor(Color(hex: "909090").opacity(0.5)) + } + .padding(.top, 26.7) + + UserProfileFanTalkView( + userId: userId, + cheers: creatorProfile.cheers, + errorPopup: { message in + viewModel.errorMessage = message + viewModel.isShowPopup = true + }, + reportPopup: { cheerId in + viewModel.reportCheersId = cheerId + viewModel.isShowCheersReportMenu = true + }, + isLoading: $viewModel.isLoading + ) + .padding(.top, 26.7) + } + } + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) { + 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(.leading) + .cornerRadius(20) + .padding(.bottom, 66.7) + Spacer() + } + } + + ZStack { + if viewModel.isShowPaymentDialog { + SodaDialog( + title: viewModel.paymentDialogTitle, + desc: viewModel.paymentDialogDesc, + confirmButtonTitle: viewModel.paymentDialogConfirmTitle, + confirmButtonAction: viewModel.paymentDialogConfirmAction, + cancelButtonTitle: viewModel.paymentDialogCancelTitle, + cancelButtonAction: viewModel.hidePaymentPopup + ) + } + + if viewModel.isShowPasswordDialog { + LiveRoomPasswordDialog( + isShowing: $viewModel.isShowPasswordDialog, + can: viewModel.secretOrPasswordDialogCan, + confirmAction: viewModel.passwordDialogConfirmAction + ) + } + + if viewModel.isShowCheersReportMenu { + VStack(spacing: 0) { + CheersReportMenuView( + isShowing: $viewModel.isShowCheersReportMenu, + onClickReport: { viewModel.isShowCheersReportView = true } + ) + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(Color(hex: "222222")) + .frame(width: proxy.size.width, height: 15.3) + } + } + .ignoresSafeArea() + } + + if viewModel.isShowCheersReportView { + CheersReportDialogView( + isShowing: $viewModel.isShowCheersReportView, + confirmAction: { reason in + viewModel.report(type: .CHEERS, reason: reason) + } + ) + } + + if let creatorProfile = viewModel.creatorProfile, viewModel.isShowReportMenu { + VStack(spacing: 0) { + ProfileReportMenuView( + isShowing: $viewModel.isShowReportMenu, + isBlockedUser: creatorProfile.isBlock, + userBlockAction: { viewModel.isShowUesrBlockConfirm = true }, + userUnBlockAction: { viewModel.userUnBlock(userId: userId) }, + userReportAction: { viewModel.isShowUesrReportView = true }, + profileReportAction: { viewModel.isShowProfileReportConfirm = true } + ) + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(Color(hex: "222222")) + .frame(width: proxy.size.width, height: 15.3) + } + } + .ignoresSafeArea() + } + + if let creatorProfile = viewModel.creatorProfile, + viewModel.isShowUesrBlockConfirm { + UserBlockConfirmDialogView( + isShowing: $viewModel.isShowUesrBlockConfirm, + nickname: creatorProfile.creator.nickname, + confirmAction: { viewModel.userBlock(userId: userId) } + ) + } + + if viewModel.isShowUesrReportView { + UserReportDialogView( + isShowing: $viewModel.isShowUesrReportView, + confirmAction: { reason in + viewModel.report(type: .USER, userId: userId, reason: reason) + } + ) + } + + if viewModel.isShowProfileReportConfirm { + ProfileReportDialogView( + isShowing: $viewModel.isShowProfileReportConfirm, + confirmAction: { + viewModel.report(type: .PROFILE, userId: userId) + } + ) + } + } + } + .sheet( + isPresented: $viewModel.isShowShareView, + onDismiss: { viewModel.shareMessage = "" }, + content: { + ActivityViewController(activityItems: [viewModel.shareMessage]) + } + ) + .onAppear { + viewModel.getCreatorProfile(userId: userId) + AppState.shared.pushChannelId = 0 + } + } + } +} + +struct UserProfileView_Previews: PreviewProvider { + static var previews: some View { + UserProfileView(userId: 0) + } +} diff --git a/SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift b/SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift new file mode 100644 index 0000000..8bf5ba7 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift @@ -0,0 +1,546 @@ +// +// UserProfileViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation +import Combine + +import FirebaseDynamicLinks + +final class UserProfileViewModel: ObservableObject { + + private var repository = ExplorerRepository() + private let liveRepository = LiveRepository() + private let reportRepository = ReportRepository() + private let userRepository = UserRepository() + private var subscription = Set() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var isExpandNotice = false + + @Published var paymentDialogTitle = "" + @Published var paymentDialogDesc = "" + @Published var isShowPaymentDialog = false + @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 isShowPasswordDialog = false + + @Published var navigationTitle = "채널" + + @Published private(set) var creatorProfile: GetCreatorProfileResponse? + + @Published var isShowShareView = false + @Published var shareMessage = "" + + @Published var isShowReportMenu = false + @Published var isShowUesrBlockConfirm = false + @Published var isShowUesrReportView = false + @Published var isShowProfileReportConfirm = false + + @Published var reportCheersId = 0 + @Published var isShowCheersReportMenu = false + @Published var isShowCheersReportView = false + + + let paymentDialogCancelTitle = "취소" + + func getCreatorProfile(userId: Int) { + creatorProfile = nil + isLoading = true + + repository.getCreatorProfile(id: userId) + .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 { + self.creatorProfile = data + self.navigationTitle = "\(data.creator.nickname)님의 채널" + } 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 hidePaymentPopup() { + isShowPaymentDialog = false + isShowSecretDialog = false + isShowPasswordDialog = false + + paymentDialogTitle = "" + paymentDialogDesc = "" + paymentDialogConfirmAction = {} + + secretOrPasswordDialogCan = 0 + secretDialogManagerNickname = "" + secretDialogConfirmAction = {} + + passwordDialogConfirmAction = { _ in } + } + + 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 + hidePaymentPopup() + reservation(roomId: roomId) + } + self.isShowPaymentDialog = true + } + } + } + } + } + + private func reservation(roomId: Int, password: Int? = nil) { + isLoading = true + let request = MakeLiveReservationRequest(roomId: roomId, password: password) + liveRepository.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.self, from: responseData) + + if let response = decoded.data, decoded.success { + 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) + } + + func enterLiveRoom(roomId: Int) { + getRoomDetail(roomId: roomId) { + if let _ = $0.channelName { + if $0.manager.id == UserDefaults.int(forKey: .userId) { + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3) { + 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 { + self.passwordDialogConfirmAction = { password in + self.enterRoom(roomId: roomId, password: password) + } + self.isShowPasswordDialog = true + } else { + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3) { + self.enterRoom(roomId: roomId) + } + } + } 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 { + self.secretOrPasswordDialogCan = $0.price + self.passwordDialogConfirmAction = { password in + self.enterRoom(roomId: roomId, password: password) + } + self.isShowPasswordDialog = true + } else { + self.paymentDialogTitle = "\($0.price)코인으로 입장" + self.paymentDialogDesc = "'\($0.title)' 라이브에 참여하기 위해 결제합니다." + self.paymentDialogConfirmTitle = "결제 후 참여하기" + self.paymentDialogConfirmAction = { [unowned self] in + hidePaymentPopup() + self.enterRoom(roomId: roomId) + } + self.isShowPaymentDialog = true + } + } + } + } + } + + func enterRoom(roomId: Int, onSuccess: (() -> Void)? = nil, password: Int? = nil) { + isLoading = true + let request = EnterOrQuitLiveRoomRequest(roomId: roomId, password: password) + liveRepository.enterRoom(request: request) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + AppState.shared.roomId = roomId + + if let onSuccess = onSuccess { + onSuccess() + } else { + if roomId > 0 { + AppState.shared.isShowPlayer = true + AppState.shared.setAppStep(step: .main) + } + } + } 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 creatorFollow() { + if let creator = creatorProfile { + isLoading = true + + userRepository.creatorFollow(creatorId: creator.creator.creatorId) + .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.getCreatorProfile(userId: creator.creator.creatorId) + } 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 creatorUnFollow() { + if let creator = creatorProfile { + isLoading = true + + userRepository.creatorUnFollow(creatorId: creator.creator.creatorId) + .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.getCreatorProfile(userId: creator.creator.creatorId) + } 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 getRoomDetail(roomId: Int, onSuccess: @escaping (GetRoomDetailResponse) -> Void) { + isLoading = true + liveRepository.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.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) + } + + func shareChannel(userId: Int) { + guard let link = URL(string: "https://yozm.day/?channel_id=\(userId)") else { return } + let dynamicLinksDomainURIPrefix = "https://yozm.page.link" + guard let linkBuilder = DynamicLinkComponents(link: link, domainURIPrefix: dynamicLinksDomainURIPrefix) else { + self.errorMessage = "공유링크를 생성하지 못했습니다.\n다시 시도해 주세요." + self.isShowPopup = true + return + } + + linkBuilder.iOSParameters = DynamicLinkIOSParameters(bundleID: "kr.co.vividnext.yozm") + linkBuilder.iOSParameters?.appStoreID = "1630284226" + + linkBuilder.androidParameters = DynamicLinkAndroidParameters(packageName: "kr.co.vividnext.yozm") + + guard let longDynamicLink = linkBuilder.url else { + self.errorMessage = "공유링크를 생성하지 못했습니다.\n다시 시도해 주세요." + self.isShowPopup = true + return + } + DEBUG_LOG("The long URL is: \(longDynamicLink)") + + DynamicLinkComponents.shortenURL(longDynamicLink, options: nil) { [unowned self] url, warnings, error in + let shortUrl = url?.absoluteString + let urlString = shortUrl != nil ? shortUrl! : longDynamicLink.absoluteString + + self.shareMessage = "요즘라이브 \(self.creatorProfile!.creator.nickname)님의 채널입니다.\n\(urlString)" + self.isShowShareView = true + } + } + + func userBlock(userId: Int) { + isLoading = true + userRepository.memberBlock(userId: userId) + .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 { + getCreatorProfile(userId: userId) + self.errorMessage = "차단하였습니다." + } 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 userUnBlock(userId: Int) { + isLoading = true + userRepository.memberUnBlock(userId: userId) + .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 { + getCreatorProfile(userId: userId) + self.errorMessage = "차단이 해제 되었습니다." + } 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 report(type: ReportType, userId: Int? = nil, reason: String = "프로필 신고") { + isLoading = true + + let request = ReportRequest(type: type, reason: reason, reportedMemberId: userId, cheersId: reportCheersId > 0 && type == .CHEERS ? reportCheersId : nil, audioContentId: nil) + reportRepository.report(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 + + self.reportCheersId = 0 + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + 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/Live/LiveApi.swift b/SodaLive/Sources/Live/LiveApi.swift index 3aaefa8..f0fc69e 100644 --- a/SodaLive/Sources/Live/LiveApi.swift +++ b/SodaLive/Sources/Live/LiveApi.swift @@ -14,6 +14,9 @@ enum LiveApi { case getReservations(isActive: Bool) case getReservation(reservationId: Int) case cancelReservation(request: CancelLiveReservationRequest) + case getRoomDetail(roomId: Int) + case makeReservation(request: MakeLiveReservationRequest) + case enterRoom(request: EnterOrQuitLiveRoomRequest) } extension LiveApi: TargetType { @@ -37,14 +40,26 @@ extension LiveApi: TargetType { case .cancelReservation: return "/live/reservation/cancel" + + case .getRoomDetail(let roomId): + return "/live/room/detail/\(roomId)" + + case .makeReservation: + return "/live/reservation" + + case .enterRoom: + return "/live/room/enter" } } var method: Moya.Method { switch self { - case .roomList, .recentVisitRoomUsers, .getReservations, .getReservation: + case .roomList, .recentVisitRoomUsers, .getReservations, .getReservation, .getRoomDetail: return .get + case .makeReservation, .enterRoom: + return .post + case .cancelReservation: return .put } @@ -92,6 +107,16 @@ extension LiveApi: TargetType { case .cancelReservation(let request): return .requestJSONEncodable(request) + + case .getRoomDetail: + let parameters = ["timezone": TimeZone.current.identifier] as [String: Any] + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + + case .makeReservation(let request): + return .requestJSONEncodable(request) + + case .enterRoom(let request): + return .requestJSONEncodable(request) } } diff --git a/SodaLive/Sources/Live/LiveRepository.swift b/SodaLive/Sources/Live/LiveRepository.swift index d5d6566..2f83900 100644 --- a/SodaLive/Sources/Live/LiveRepository.swift +++ b/SodaLive/Sources/Live/LiveRepository.swift @@ -32,4 +32,16 @@ final class LiveRepository { func cancelReservation(reservationId: Int, reason: String) -> AnyPublisher { return api.requestPublisher(.cancelReservation(request: CancelLiveReservationRequest(reservationId: reservationId, reason: reason))) } + + func getRoomDetail(roomId: Int) -> AnyPublisher { + return api.requestPublisher(.getRoomDetail(roomId: roomId)) + } + + func makeReservation(request: MakeLiveReservationRequest) -> AnyPublisher { + return api.requestPublisher(.makeReservation(request: request)) + } + + func enterRoom(request: EnterOrQuitLiveRoomRequest) -> AnyPublisher { + return api.requestPublisher(.enterRoom(request: request)) + } } diff --git a/SodaLive/Sources/Live/Reservation/Complete/LiveReservationCompleteView.swift b/SodaLive/Sources/Live/Reservation/Complete/LiveReservationCompleteView.swift new file mode 100644 index 0000000..5d4d011 --- /dev/null +++ b/SodaLive/Sources/Live/Reservation/Complete/LiveReservationCompleteView.swift @@ -0,0 +1,203 @@ +// +// LiveReservationCompleteView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI + +struct LiveReservationCompleteView: View { + + let reservationCompleteData: MakeLiveReservationResponse + + var body: some View { + BaseView { + VStack(spacing: 0) { + DetailNavigationBar(title: "라이브 예약 완료") { + AppState.shared.setAppStep(step: .main) + } + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + Text("예약이 완료되었습니다.") + .font(.custom(Font.bold.rawValue, size: 20)) + .foregroundColor(Color(hex: "a285eb")) + .frame(width: screenSize().width - 26.7, alignment: .leading) + .padding(.top, 20) + + Image("img_compleate_book") + .resizable() + .scaledToFit() + .frame(width: 233.25) + .padding(.top, 16.7) + .padding(.bottom, 26.7) + + Text("라이브 예약정보") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(width: screenSize().width - 53.4, alignment: .leading) + + VStack(spacing: 6.7) { + HStack(spacing: 26.7) { + Text("채널") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "777777")) + + Text(reservationCompleteData.nickname) + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + + HStack(spacing: 26.7) { + Text("구매내역") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "777777")) + + Text(reservationCompleteData.title) + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + + HStack(spacing: 26.7) { + Text("예약일자") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "777777")) + + Text(reservationCompleteData.beginDateString) + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + + HStack(spacing: 26.7) { + Text("라이브 비용") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "777777")) + + Text(reservationCompleteData.price) + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + } + .padding(.top, 16.7) + + Rectangle() + .frame(width: screenSize().width, height: 6.7) + .foregroundColor(Color(hex: "232323")) + .padding(.vertical, 20) + + Text("결제정보") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(width: screenSize().width - 53.4, alignment: .leading) + + VStack(spacing: 13.3) { + HStack(spacing: 0) { + Text("보유코인") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "777777")) + + Spacer() + + Text("\(reservationCompleteData.haveCan)") + .font(.custom(Font.bold.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Text(" 코인") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + + HStack(spacing: 0) { + Text("결제코인") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "777777")) + + Spacer() + + Text("\(reservationCompleteData.useCan)") + .font(.custom(Font.bold.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Text(" 코인") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + + HStack(spacing: 0) { + Text("잔여코인") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "777777")) + + Spacer() + + Text("\(reservationCompleteData.remainingCan)") + .font(.custom(Font.bold.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Text(" 코인") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + } + .padding(.top, 20) + + HStack(spacing: 13.3) { + Text("홈으로 이동") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "9970ff")) + .padding(.vertical, 16) + .frame(width: (screenSize().width - 40) / 2) + .background(Color(hex: "9970ff").opacity(0.2)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(lineWidth: 1) + .foregroundColor(Color(hex: "9970ff")) + ) + .onTapGesture { + AppState.shared.setAppStep(step: .main) + } + + Text("예약 내역 이동") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.white) + .padding(.vertical, 16) + .frame(width: (screenSize().width - 40) / 2) + .background(Color(hex: "9970ff")) + .cornerRadius(10) + .onTapGesture { + AppState.shared.setAppStep(step: .liveReservation) + } + } + .padding(.vertical, 26.7) + } + } + } + } + } +} + +struct LiveReservationCompleteView_Previews: PreviewProvider { + static var previews: some View { + LiveReservationCompleteView( + reservationCompleteData: MakeLiveReservationResponse( + reservationId: 10, + nickname: "김상담", + title: "여자들이 좋아하는 남자 스타일은?", + beginDateString: "2021년 7월 9일 (금), 오후 02:00", + price: "무료", + haveCan: 100, + useCan: 0, + remainingCan: 100 + ) + ) + } +} diff --git a/SodaLive/Sources/Live/Reservation/MakeLiveReservationRequest.swift b/SodaLive/Sources/Live/Reservation/MakeLiveReservationRequest.swift new file mode 100644 index 0000000..21dc70e --- /dev/null +++ b/SodaLive/Sources/Live/Reservation/MakeLiveReservationRequest.swift @@ -0,0 +1,15 @@ +// +// MakeLiveReservationRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct MakeLiveReservationRequest: Encodable { + let roomId: Int + let password: Int? + let container: String = "ios" + let timezone: String = TimeZone.current.identifier +} diff --git a/SodaLive/Sources/Live/Reservation/MakeLiveReservationResponse.swift b/SodaLive/Sources/Live/Reservation/MakeLiveReservationResponse.swift new file mode 100644 index 0000000..06da5ff --- /dev/null +++ b/SodaLive/Sources/Live/Reservation/MakeLiveReservationResponse.swift @@ -0,0 +1,19 @@ +// +// MakeLiveReservationResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct MakeLiveReservationResponse: Decodable { + let reservationId: Int + let nickname: String + let title: String + let beginDateString: String + let price: String + let haveCan: Int + let useCan: Int + let remainingCan: Int +} diff --git a/SodaLive/Sources/Live/Room/Detail/GetRoomDetailResponse.swift b/SodaLive/Sources/Live/Room/Detail/GetRoomDetailResponse.swift index 62cb9e1..ff631bc 100644 --- a/SodaLive/Sources/Live/Room/Detail/GetRoomDetailResponse.swift +++ b/SodaLive/Sources/Live/Room/Detail/GetRoomDetailResponse.swift @@ -7,6 +7,37 @@ import Foundation +struct GetRoomDetailResponse: Decodable { + let roomId: Int + let price: Int + let title: String + let content: String + let isPaid: Bool + let isPrivateRoom: Bool + let isSecretRoom: Bool + let password: Int? + let tags: [String] + let channelName: String? + let beginDateTime: String + let isNotification: Bool + let numberOfParticipants: Int + let numberOfParticipantsTotal: Int + let manager: GetRoomDetailManager + let participatingUsers: [GetRoomDetailUser] +} + +struct GetRoomDetailManager: Decodable { + let id: Int + let nickname: String + let introduce: String + let youtubeUrl: String? + let instagramUrl: String? + let websiteUrl: String? + let blogUrl: String? + let profileImageUrl: String + let isCounselor: Bool +} + struct GetRoomDetailUser: Decodable, Hashable { let id: Int let nickname: String diff --git a/SodaLive/Sources/Live/Room/EnterOrQuitLiveRoomRequest.swift b/SodaLive/Sources/Live/Room/EnterOrQuitLiveRoomRequest.swift new file mode 100644 index 0000000..f6624fe --- /dev/null +++ b/SodaLive/Sources/Live/Room/EnterOrQuitLiveRoomRequest.swift @@ -0,0 +1,14 @@ +// +// EnterOrQuitLiveRoomRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct EnterOrQuitLiveRoomRequest: Encodable { + let roomId: Int + let container: String = "ios" + var password: Int? = nil +} diff --git a/SodaLive/Sources/MyPage/MyPageView.swift b/SodaLive/Sources/MyPage/MyPageView.swift index ff5ce0d..7fb00dc 100644 --- a/SodaLive/Sources/MyPage/MyPageView.swift +++ b/SodaLive/Sources/MyPage/MyPageView.swift @@ -80,7 +80,9 @@ struct MyPageView: View { .stroke(Color(hex: "9970ff"), lineWidth: 1.3) ) .padding(.top, 26.7) - .onTapGesture {} + .onTapGesture { + AppState.shared.setAppStep(step: .creatorDetail(userId: UserDefaults.int(forKey: .userId))) + } } CanCardView(data: data) { diff --git a/SodaLive/Sources/Report/CheersReportDialogView.swift b/SodaLive/Sources/Report/CheersReportDialogView.swift new file mode 100644 index 0000000..b31b9ea --- /dev/null +++ b/SodaLive/Sources/Report/CheersReportDialogView.swift @@ -0,0 +1,96 @@ +// +// CheersReportDialogView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI + +struct CheersReportDialogView: View { + + @Binding var isShowing: Bool + let confirmAction: (String) -> Void + + @State private var selectedIndex: Int? = nil + let reasons = [ + "원치 않는 상업성 콘텐츠 또는 스팸", + "아동 학대", + "증오심 표현 또는 노골적인 폭력", + "테러 조장", + "희롱 또는 괴롭힘", + "자살 또는 자해", + "잘못된 정보" + ] + + var body: some View { + ZStack { + Color.black + .opacity(0.7) + .ignoresSafeArea() + .onTapGesture { isShowing = false } + + VStack(spacing: 13.3) { + Text("응원글 신고") + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + + VStack(spacing: 13.3) { + ForEach(0.. Void + + var body: some View { + ZStack { + Color.black + .opacity(0.7) + .ignoresSafeArea() + .onTapGesture { isShowing = false } + + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 13.3) { + HStack(spacing: 0) { + Text("신고하기") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(.white) + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 26.7) + .contentShape(Rectangle()) + .onTapGesture { + isShowing = false + onClickReport() + } + } + .padding(24) + .background(Color(hex: "222222")) + .cornerRadius(13.3, corners: [.topLeft, .topRight]) + } + } + } +} diff --git a/SodaLive/Sources/Report/ProfileReportDialogView.swift b/SodaLive/Sources/Report/ProfileReportDialogView.swift new file mode 100644 index 0000000..31694e4 --- /dev/null +++ b/SodaLive/Sources/Report/ProfileReportDialogView.swift @@ -0,0 +1,57 @@ +// +// ProfileReportDialogView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI + +struct ProfileReportDialogView: View { + + @Binding var isShowing: Bool + let confirmAction: () -> Void + + var body: some View { + ZStack { + Color.black + .opacity(0.7) + .ignoresSafeArea() + .onTapGesture { isShowing = false } + + VStack(spacing: 13.3) { + Text("프로필 사진 신고") + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Text("신고제도를 남용할 경우, 계정에 제약이 있을 수 있습니다.\n프로필 사진을 신고하시겠습니까?") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "909090")) + + HStack(spacing: 26.7) { + Spacer() + + Text("취소") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "9970ff")) + .onTapGesture { + isShowing = false + } + + Text("신고") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "9970ff")) + .onTapGesture { + isShowing = false + confirmAction() + } + } + .padding(.top, 13.3) + } + .padding(24) + .frame(width: screenSize().width - 33.3) + .background(Color(hex: "222222")) + .cornerRadius(13.3) + } + } +} diff --git a/SodaLive/Sources/Report/ProfileReportMenuView.swift b/SodaLive/Sources/Report/ProfileReportMenuView.swift new file mode 100644 index 0000000..8a5aa99 --- /dev/null +++ b/SodaLive/Sources/Report/ProfileReportMenuView.swift @@ -0,0 +1,86 @@ +// +// ProfileReportMenuView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI + +struct ProfileReportMenuView: View { + + @Binding var isShowing: Bool + + let isBlockedUser: Bool + let userBlockAction: () -> Void + let userUnBlockAction: () -> Void + let userReportAction: () -> Void + let profileReportAction: () -> Void + + var body: some View { + ZStack { + Color.black + .opacity(0.7) + .ignoresSafeArea() + .onTapGesture { isShowing = false } + + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 13.3) { + HStack(spacing: 0) { + Text(isBlockedUser ? "사용자 차단해제" : "사용자 차단하기") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(.white) + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 26.7) + .contentShape(Rectangle()) + .onTapGesture { + isShowing = false + if isBlockedUser { + userUnBlockAction() + } else { + userBlockAction() + } + } + + HStack(spacing: 0) { + Text("사용자 신고하기") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(.white) + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 26.7) + .contentShape(Rectangle()) + .onTapGesture { + isShowing = false + userReportAction() + } + + HStack(spacing: 0) { + Text("프로필 신고하기") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(.white) + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 26.7) + .contentShape(Rectangle()) + .onTapGesture { + isShowing = false + profileReportAction() + } + } + .padding(24) + .background(Color(hex: "222222")) + .cornerRadius(13.3, corners: [.topLeft, .topRight]) + } + } + } +} diff --git a/SodaLive/Sources/Report/ReportApi.swift b/SodaLive/Sources/Report/ReportApi.swift new file mode 100644 index 0000000..218deb8 --- /dev/null +++ b/SodaLive/Sources/Report/ReportApi.swift @@ -0,0 +1,44 @@ +// +// ReportApi.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation +import Moya + +enum ReportApi { + case report(request: ReportRequest) +} + +extension ReportApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .report: + return "/report" + } + } + + var method: Moya.Method { + switch self { + case .report: + return .post + } + } + + var task: Task { + switch self { + case .report(let request): + return .requestJSONEncodable(request) + } + } + + var headers: [String : String]? { + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} diff --git a/SodaLive/Sources/Report/ReportRepository.swift b/SodaLive/Sources/Report/ReportRepository.swift new file mode 100644 index 0000000..d2017b0 --- /dev/null +++ b/SodaLive/Sources/Report/ReportRepository.swift @@ -0,0 +1,19 @@ +// +// ReportRepository.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation +import CombineMoya +import Combine +import Moya + +class ReportRepository { + private let api = MoyaProvider() + + func report(request: ReportRequest) -> AnyPublisher { + return api.requestPublisher(.report(request: request)) + } +} diff --git a/SodaLive/Sources/Report/ReportRequest.swift b/SodaLive/Sources/Report/ReportRequest.swift new file mode 100644 index 0000000..9333eb0 --- /dev/null +++ b/SodaLive/Sources/Report/ReportRequest.swift @@ -0,0 +1,21 @@ +// +// ReportRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct ReportRequest: Encodable { + let type: ReportType + let reason: String + let reportedMemberId: Int? + let cheersId: Int? + let audioContentId: Int? +} + +enum ReportType: String, Codable { + case PROFILE, USER, CHEERS, AUDIO_CONTENT +} + diff --git a/SodaLive/Sources/Report/UserBlockConfirmDialogView.swift b/SodaLive/Sources/Report/UserBlockConfirmDialogView.swift new file mode 100644 index 0000000..7c88bae --- /dev/null +++ b/SodaLive/Sources/Report/UserBlockConfirmDialogView.swift @@ -0,0 +1,75 @@ +// +// UserBlockConfirmDialogView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI + +struct UserBlockConfirmDialogView: View { + + @Binding var isShowing: Bool + + let nickname: String + let confirmAction: () -> Void + + let notice = """ +사용자를 차단하면 사용자는 아래 기능이 제한됩니다. + +- 내가 개설한 라이브 입장 불가 +- 나에게 메시지 보내기 불가 +- 내 채널의 팬Talk 작성불가 +""" + + var body: some View { + ZStack { + Color.black + .opacity(0.7) + .ignoresSafeArea() + .onTapGesture { isShowing = false } + + VStack(spacing: 13.3) { + Text("사용자 차단") + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(.white) + + Text("\(nickname)님을 차단하시겠습니까?") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(.white) + + HStack(spacing: 0) { + Text(notice) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(.white) + + Spacer() + } + + HStack(spacing: 26.7) { + Spacer() + + Text("취소") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "9970ff")) + .onTapGesture { + isShowing = false + } + + Text("차단") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "9970ff")) + .onTapGesture { + isShowing = false + confirmAction() + } + } + .padding(.top, 13.3) + } + .padding(24) + .frame(width: screenSize().width - 33.3) + .background(Color(hex: "222222")) + .cornerRadius(13.3) + } + } +} diff --git a/SodaLive/Sources/Report/UserReportDialogView.swift b/SodaLive/Sources/Report/UserReportDialogView.swift new file mode 100644 index 0000000..af67e2a --- /dev/null +++ b/SodaLive/Sources/Report/UserReportDialogView.swift @@ -0,0 +1,88 @@ +// +// UserReportDialogView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI + +struct UserReportDialogView: View { + + @Binding var isShowing: Bool + let confirmAction: (String) -> Void + + @State private var selectedIndex: Int? = nil + let reasons = [ + "괴롭힘 및 사이버 폭력", + "개인정보 침해", + "명의 도용", + "폭력적 위협", + "아동 학대", + "보호 대상 집단에 대한 증오심 표현", + "스팸 및 사기", + "나에게 해당하는 문제 없음" + ] + + var body: some View { + ZStack { + Color.black + .opacity(0.7) + .ignoresSafeArea() + .onTapGesture { isShowing = false } + + VStack(spacing: 13.3) { + Text("사용자 신고") + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + + VStack(spacing: 13.3) { + ForEach(0.. AnyPublisher { return api.requestPublisher(.updatePushToken(request: PushTokenUpdateRequest(pushToken: pushToken))) } + + func creatorFollow(creatorId: Int) -> AnyPublisher { + return api.requestPublisher(.creatorFollow(request: CreatorFollowRequest(creatorId: creatorId))) + } + + func creatorUnFollow(creatorId: Int) -> AnyPublisher { + return api.requestPublisher(.creatorUnFollow(request: CreatorFollowRequest(creatorId: creatorId))) + } + + func memberBlock(userId: Int) -> AnyPublisher { + return api.requestPublisher(.memberBlock(request: MemberBlockRequest(blockMemberId: userId))) + } + + func memberUnBlock(userId: Int) -> AnyPublisher { + return api.requestPublisher(.memberUnBlock(request: MemberBlockRequest(blockMemberId: userId))) + } }