diff --git a/SodaLive/Resources/Assets.xcassets/btn_plus_round.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_plus_round.imageset/Contents.json new file mode 100644 index 0000000..fef8ba9 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_plus_round.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "btn_plus_round.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_plus_round.imageset/btn_plus_round.png b/SodaLive/Resources/Assets.xcassets/btn_plus_round.imageset/btn_plus_round.png new file mode 100644 index 0000000..d66ac27 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_plus_round.imageset/btn_plus_round.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_make_message.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_make_message.imageset/Contents.json new file mode 100644 index 0000000..5a12eb3 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_make_message.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_make_message.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_make_message.imageset/ic_make_message.png b/SodaLive/Resources/Assets.xcassets/ic_make_message.imageset/ic_make_message.png new file mode 100644 index 0000000..5209612 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_make_message.imageset/ic_make_message.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_make_voice.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_make_voice.imageset/Contents.json new file mode 100644 index 0000000..cdaf3f6 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_make_voice.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_make_voice.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_make_voice.imageset/ic_make_voice.png b/SodaLive/Resources/Assets.xcassets/ic_make_voice.imageset/ic_make_voice.png new file mode 100644 index 0000000..bec10a3 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_make_voice.imageset/ic_make_voice.png differ diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 75d819e..c21729c 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -15,4 +15,10 @@ enum AppStep { case signUp case findPassword + + case textMessageDetail(messageItem: TextMessageItem, messageBox: MessageFilterTab, refresh: () -> Void) + + case writeTextMessage(userId: Int?, nickname: String?) + + case writeVoiceMessage(userId: Int?, nickname: String?, onRefresh: () -> Void) } diff --git a/SodaLive/Sources/Common/TextViewWrapper.swift b/SodaLive/Sources/Common/TextViewWrapper.swift new file mode 100644 index 0000000..30a18f8 --- /dev/null +++ b/SodaLive/Sources/Common/TextViewWrapper.swift @@ -0,0 +1,100 @@ +// +// TextViewWrapper.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI + +struct TextViewWrapper: UIViewRepresentable { + + @Binding var text: String + + var placeholder: String + var textColorHex: String + var backgroundColorHex: String + + var notice: String? = nil + + func makeUIView(context: Context) -> UITextView { + let view = UITextView() + + if let notice = notice { + view.text = notice.trimmingCharacters(in: .whitespacesAndNewlines) != "" ? notice : placeholder + view.textColor = notice.trimmingCharacters(in: .whitespacesAndNewlines) != "" ? UIColor(hex: textColorHex) : .placeholderText + } else { + view.text = text.trimmingCharacters(in: .whitespacesAndNewlines) != "" ? text : placeholder + view.textColor = text.trimmingCharacters(in: .whitespacesAndNewlines) != "" ? UIColor(hex: textColorHex) : .placeholderText + } + + view.font = UIFont(name: Font.medium.rawValue, size: 13.3) + view.backgroundColor = backgroundColorHex.isEmpty ? .clear : UIColor(hex: backgroundColorHex) + view.layer.cornerRadius = 6.7 + view.textContainerInset = UIEdgeInsets(top: 20, left: 13.3, bottom: 20, right: 13.3) + + view.delegate = context.coordinator + + return view + } + + func updateUIView(_ uiView: UITextView, context: Context) { + if text.trimmingCharacters(in: .whitespacesAndNewlines) != "" { + uiView.text = text + uiView.textColor = UIColor(hex: textColorHex) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator($text, placeholder: placeholder, textColorHex: textColorHex) + } + + class Coordinator: NSObject { + var text: Binding + var placeholder: String + var textColorHex: String + + init(_ text: Binding, placeholder: String, textColorHex: String) { + self.text = text + self.placeholder = placeholder + self.textColorHex = textColorHex + } + } +} + +extension TextViewWrapper.Coordinator: UITextViewDelegate { + func textViewDidBeginEditing(_ textView: UITextView) { + if textView.textColor == .placeholderText { + DispatchQueue.main.async { [unowned self] in + self.text.wrappedValue = "" + textView.text = "" + textView.textColor = UIColor(hex: textColorHex) + } + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + if textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + DispatchQueue.main.async { [unowned self] in + self.text.wrappedValue = "" + textView.text = self.placeholder + textView.textColor = .placeholderText + } + } + } + + func textViewDidChange(_ textView: UITextView) { + self.text.wrappedValue = textView.text + } +} + +struct TextViewWrapper_Previews: PreviewProvider { + static var previews: some View { + TextViewWrapper( + text: .constant(""), + placeholder: "입력하세요", + textColorHex: "777777", + backgroundColorHex: "222222" + ) + } +} diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index aa4b2d4..21e2248 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -26,6 +26,15 @@ struct ContentView: View { case .findPassword: FindPasswordView() + case .textMessageDetail(let messageItem, let messageBox, let refresh): + TextMessageDetailView(messageItem: messageItem, messageBox: messageBox, refresh: refresh) + + case .writeTextMessage(let userId, let nickname): + TextMessageWriteView(replySenderId: userId, replySenderNickname: nickname) + + case .writeVoiceMessage(let userId, let nickname, let onRefresh): + VoiceMessageWriteView(replySenderId: userId, replySenderNickname: nickname, onRefresh: onRefresh) + default: EmptyView() .frame(width: 0, height: 0, alignment: .topLeading) diff --git a/SodaLive/Sources/Live/LiveApi.swift b/SodaLive/Sources/Live/LiveApi.swift index ba871ba..4c93c12 100644 --- a/SodaLive/Sources/Live/LiveApi.swift +++ b/SodaLive/Sources/Live/LiveApi.swift @@ -10,6 +10,7 @@ import Moya enum LiveApi { case roomList(request: GetRoomListRequest) + case recentVisitRoomUsers } extension LiveApi: TargetType { @@ -21,12 +22,15 @@ extension LiveApi: TargetType { switch self { case .roomList: return "/live/room" + + case .recentVisitRoomUsers: + return "/live/room/recent_visit_room/users" } } var method: Moya.Method { switch self { - case .roomList: + case .roomList, .recentVisitRoomUsers: return .get } } @@ -48,6 +52,9 @@ extension LiveApi: TargetType { return .requestParameters( parameters: parameters, encoding: URLEncoding.queryString) + + case .recentVisitRoomUsers: + return .requestPlain } } diff --git a/SodaLive/Sources/Live/LiveRepository.swift b/SodaLive/Sources/Live/LiveRepository.swift index 7d3e77d..0555789 100644 --- a/SodaLive/Sources/Live/LiveRepository.swift +++ b/SodaLive/Sources/Live/LiveRepository.swift @@ -16,4 +16,8 @@ final class LiveRepository { func roomList(request: GetRoomListRequest) -> AnyPublisher { return api.requestPublisher(.roomList(request: request)) } + + func recentVisitRoomUsers() -> AnyPublisher { + return api.requestPublisher(.recentVisitRoomUsers) + } } diff --git a/SodaLive/Sources/Message/KeepMessageRequest.swift b/SodaLive/Sources/Message/KeepMessageRequest.swift new file mode 100644 index 0000000..77650c3 --- /dev/null +++ b/SodaLive/Sources/Message/KeepMessageRequest.swift @@ -0,0 +1,12 @@ +// +// KeepMessageRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation + +struct KeepMessageRequest: Encodable { + let container = "ios" +} diff --git a/SodaLive/Sources/Message/MessageApi.swift b/SodaLive/Sources/Message/MessageApi.swift new file mode 100644 index 0000000..e49ac80 --- /dev/null +++ b/SodaLive/Sources/Message/MessageApi.swift @@ -0,0 +1,128 @@ +// +// MessageApi.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Moya + +enum MessageApi { + case getSentTextMessage(page: Int, size: Int) + case getReceivedTextMessage(page: Int, size: Int) + case getKeepTextMessage(page: Int, size: Int) + case sendTextMessage(request: SendTextMessageRequest) + case keepTextMessage(messageId: Int) + + case getSentVoiceMessage(page: Int, size: Int) + case getReceivedVoiceMessage(page: Int, size: Int) + case getKeepVoiceMessage(page: Int, size: Int) + case sendVoiceMessage(parameters: [MultipartFormData]) + case keepVoiceMessage(messageId: Int) + + case deleteMessage(messageId: Int) +} + +extension MessageApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getSentTextMessage: + return "/message/sent/text" + + case .getReceivedTextMessage: + return "/message/received/text" + + case .getKeepTextMessage: + return "/message/keep/text" + + case .sendTextMessage: + return "/message/send/text" + + case .keepTextMessage(let messageId): + return "/message/keep/text/\(messageId)" + + case .getSentVoiceMessage: + return "/message/sent/voice" + + case .getReceivedVoiceMessage: + return "/message/received/voice" + + case .getKeepVoiceMessage: + return "/message/keep/voice" + + case .sendVoiceMessage: + return "/message/send/voice" + + case .keepVoiceMessage(let messageId): + return "/message/keep/voice/\(messageId)" + + case .deleteMessage(let messageId): + return "/message/\(messageId)" + } + } + + var method: Moya.Method { + switch self { + case .getSentTextMessage, .getReceivedTextMessage, .getKeepTextMessage: + return .get + + case .sendTextMessage: + return .post + + case .keepTextMessage: + return .put + + case .getSentVoiceMessage, .getReceivedVoiceMessage, .getKeepVoiceMessage: + return .get + + case .sendVoiceMessage: + return .post + + case .keepVoiceMessage: + return .put + + case .deleteMessage: + return .delete + } + } + + var task: Task { + switch self { + case .getSentTextMessage(let page, let size), + .getReceivedTextMessage(let page, let size), + .getKeepTextMessage(let page, let size), + .getSentVoiceMessage(let page, let size), + .getReceivedVoiceMessage(let page, let size), + .getKeepVoiceMessage(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 .sendTextMessage(let request): + return .requestJSONEncodable(request) + + case .sendVoiceMessage(let parameters): + return .uploadMultipart(parameters) + + case .deleteMessage: + return .requestPlain + + case .keepTextMessage, .keepVoiceMessage: + return .requestJSONEncodable(KeepMessageRequest()) + } + } + + var headers: [String : String]? { + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} diff --git a/SodaLive/Sources/Message/MessageFilterTab.swift b/SodaLive/Sources/Message/MessageFilterTab.swift new file mode 100644 index 0000000..ca5a33d --- /dev/null +++ b/SodaLive/Sources/Message/MessageFilterTab.swift @@ -0,0 +1,12 @@ +// +// MessageFilterTab.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation + +enum MessageFilterTab { + case receive, sent, keep +} diff --git a/SodaLive/Sources/Message/MessageFilterTabView.swift b/SodaLive/Sources/Message/MessageFilterTabView.swift new file mode 100644 index 0000000..92a4f79 --- /dev/null +++ b/SodaLive/Sources/Message/MessageFilterTabView.swift @@ -0,0 +1,77 @@ +// +// MessageFilterTabView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI + +struct MessageFilterTabView: View { + + @Binding var currentFilterTab: MessageFilterTab + + var body: some View { + HStack(spacing: 6.7) { + Text("받은 메시지") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: currentFilterTab == .receive ? "9970ff" : "777777")) + .padding(.horizontal, 25) + .padding(.vertical, 10.7) + .overlay( + RoundedRectangle(cornerRadius: 16.7) + .stroke( + Color(hex: currentFilterTab == .receive ? "9970ff" : "777777"), + lineWidth: 1 + ) + ) + .onTapGesture { + if currentFilterTab != .receive { + currentFilterTab = .receive + } + } + + Text("보낸 메시지") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: currentFilterTab == .sent ? "9970ff" : "777777")) + .padding(.horizontal, 25) + .padding(.vertical, 10.7) + .overlay( + RoundedRectangle(cornerRadius: 16.7) + .stroke( + Color(hex: currentFilterTab == .sent ? "9970ff" : "777777"), + lineWidth: 1 + ) + ) + .onTapGesture { + if currentFilterTab != .sent { + currentFilterTab = .sent + } + } + + Text("보관함") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: currentFilterTab == .keep ? "9970ff" : "777777")) + .padding(.horizontal, 25) + .padding(.vertical, 10.7) + .overlay( + RoundedRectangle(cornerRadius: 16.7) + .stroke( + Color(hex: currentFilterTab == .keep ? "9970ff" : "777777"), + lineWidth: 1 + ) + ) + .onTapGesture { + if currentFilterTab != .keep { + currentFilterTab = .keep + } + } + } + } +} + +struct MessageFilterTabView_Previews: PreviewProvider { + static var previews: some View { + MessageFilterTabView(currentFilterTab: .constant(.receive)) + } +} diff --git a/SodaLive/Sources/Message/MessageRepository.swift b/SodaLive/Sources/Message/MessageRepository.swift new file mode 100644 index 0000000..c32e55e --- /dev/null +++ b/SodaLive/Sources/Message/MessageRepository.swift @@ -0,0 +1,59 @@ +// +// MessageRepository.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import CombineMoya +import Combine +import Moya + +final class MessageRepository { + private let api = MoyaProvider() + + func getReceivedTextMessage(page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getReceivedTextMessage(page: page, size: size)) + } + + func getSentTextMessage(page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getSentTextMessage(page: page, size: size)) + } + + func getKeepTextMessage(page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getKeepTextMessage(page: page, size: size)) + } + + func sendTextMessage(request: SendTextMessageRequest) -> AnyPublisher { + return api.requestPublisher(.sendTextMessage(request:request)) + } + + func keepTextMessage(messageId: Int) -> AnyPublisher { + return api.requestPublisher(.keepTextMessage(messageId: messageId)) + } + + func getReceivedVoiceMessage(page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getReceivedVoiceMessage(page: page, size: size)) + } + + func getSentVoiceMessage(page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getSentVoiceMessage(page: page, size: size)) + } + + func getKeepVoiceMessage(page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getKeepVoiceMessage(page: page, size: size)) + } + + func sendVoiceMessage(parameters: [MultipartFormData]) -> AnyPublisher { + return api.requestPublisher(.sendVoiceMessage(parameters: parameters)) + } + + func keepVoiceMessage(messageId: Int) -> AnyPublisher { + return api.requestPublisher(.keepVoiceMessage(messageId: messageId)) + } + + func deleteMessage(messageId: Int) -> AnyPublisher { + return api.requestPublisher(.deleteMessage(messageId: messageId)) + } +} diff --git a/SodaLive/Sources/Message/MessageView.swift b/SodaLive/Sources/Message/MessageView.swift index e117088..07ed81f 100644 --- a/SodaLive/Sources/Message/MessageView.swift +++ b/SodaLive/Sources/Message/MessageView.swift @@ -8,8 +8,81 @@ import SwiftUI struct MessageView: View { + + @StateObject var viewModel = MessageViewModel() + var body: some View { - Text("Message") + GeometryReader { geo in + VStack { + HomeNavigationBar(title: "메시지") {} + + Tab() + + Text("※ 보관하지 않은 받은 메시지는 3일 후, 자동 삭제됩니다.") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .padding(.top, 20) + + switch viewModel.currentTab { + case .text: + TextMessageView() + + case .voice: + VoiceMessageView() + } + } + .frame(width: geo.size.width, height: geo.size.height) + } + } + + @ViewBuilder + func Tab() -> some View { + let tabWidth = screenSize().width / 2 + + VStack(spacing:0) { + HStack(spacing: 0) { + Button(action: { + if viewModel.currentTab != .text { + viewModel.currentTab = .text + } + }) { + VStack(spacing: 0) { + Text("문자") + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color(hex: viewModel.currentTab == .text ? "eeeeee" : "777777")) + .frame(width: tabWidth, height: 50) + + if viewModel.currentTab == .text { + Rectangle() + .foregroundColor(Color(hex: "9970ff")) + .frame(width: tabWidth, height: 3) + } + } + } + + Button(action: { + if viewModel.currentTab != .voice { + viewModel.currentTab = .voice + } + }) { + VStack(spacing: 0) { + Text("음성") + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color(hex: viewModel.currentTab == .voice ? "eeeeee" : "777777")) + .frame(width: tabWidth, height: 50) + + if viewModel.currentTab == .voice { + Rectangle() + .foregroundColor(Color(hex: "9970ff")) + .frame(width: tabWidth, height: 3) + } + } + } + } + + Rectangle() + .frame(width: screenSize().width, height: 1) + .foregroundColor(Color(hex: "909090")) + } } } diff --git a/SodaLive/Sources/Message/MessageViewModel.swift b/SodaLive/Sources/Message/MessageViewModel.swift new file mode 100644 index 0000000..dec5d50 --- /dev/null +++ b/SodaLive/Sources/Message/MessageViewModel.swift @@ -0,0 +1,17 @@ +// +// MessageViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation + +final class MessageViewModel: ObservableObject { + + enum CurrentTab: String { + case text, voice + } + + @Published var currentTab: CurrentTab = .text +} diff --git a/SodaLive/Sources/Message/SelectRecipient/SelectRecipientView.swift b/SodaLive/Sources/Message/SelectRecipient/SelectRecipientView.swift new file mode 100644 index 0000000..07e78b0 --- /dev/null +++ b/SodaLive/Sources/Message/SelectRecipient/SelectRecipientView.swift @@ -0,0 +1,74 @@ +// +// SelectRecipientView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI +import Kingfisher + +struct SelectRecipientView: View { + + @ObservedObject var viewModel = SelectRecipientViewModel() + + @Binding var isShowing: Bool + let selectUser: (GetRoomDetailUser) -> Void + + var body: some View { + BaseView { + VStack(spacing: 20) { + DetailNavigationBar(title: "받는 사람 검색") { + isShowing = false + } + + TextField("닉네임을 입력해주세요", text: $viewModel.searchNickname) + .autocapitalization(.none) + .disableAutocorrection(true) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + .keyboardType(.default) + .padding(.horizontal, 13.3) + .frame(width: screenSize().width - 26.7, height: 50) + .background(Color(hex: "232323")) + .cornerRadius(10) + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 26.7) { + ForEach(viewModel.users, id: \.self) { user in + HStack(spacing: 13.3) { + KFImage(URL(string: user.profileImageUrl)) + .resizable() + .scaledToFill() + .frame(width: 46.7, height: 46.7, alignment: .top) + .clipped() + .cornerRadius(23.3) + + Text(user.nickname) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + selectUser(user) + isShowing = false + } + } + } + } + .frame(width: screenSize().width - 26.7) + } + } + .onAppear { + viewModel.searchUser() + } + } +} + +struct SelectRecipientView_Previews: PreviewProvider { + static var previews: some View { + SelectRecipientView(isShowing: .constant(true)) { _ in } + } +} diff --git a/SodaLive/Sources/Message/SelectRecipient/SelectRecipientViewModel.swift b/SodaLive/Sources/Message/SelectRecipient/SelectRecipientViewModel.swift new file mode 100644 index 0000000..0fc712b --- /dev/null +++ b/SodaLive/Sources/Message/SelectRecipient/SelectRecipientViewModel.swift @@ -0,0 +1,102 @@ +// +// SelectRecipientViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Combine + +final class SelectRecipientViewModel: ObservableObject { + + private let liveRepository = LiveRepository() + private let userRepository = UserRepository() + private var subscription = Set() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var searchNickname = "" { + didSet { + searchUser() + } + } + + @Published var users = [GetRoomDetailUser]() + + func searchUser() { + if searchNickname.count > 1 { + userRepository.searchUser(nickname: searchNickname) + .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<[GetRoomDetailUser]>.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.users.removeAll() + self.users.append(contentsOf: data) + } else { + if let message = decoded.message { + DEBUG_LOG("message: \(message)") + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } else { + liveRepository.recentVisitRoomUsers() + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse<[GetRoomDetailUser]>.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.users.removeAll() + self.users.append(contentsOf: data) + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + } +} diff --git a/SodaLive/Sources/Message/SendMessageRequest.swift b/SodaLive/Sources/Message/SendMessageRequest.swift new file mode 100644 index 0000000..f41d2ad --- /dev/null +++ b/SodaLive/Sources/Message/SendMessageRequest.swift @@ -0,0 +1,19 @@ +// +// SendMessageRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation + +struct SendTextMessageRequest: Encodable { + let recipientId: Int + let textMessage: String + let container: String = "ios" +} + +struct SendVoiceMessageRequest: Encodable { + let recipientId: Int + let container: String = "ios" +} diff --git a/SodaLive/Sources/Message/Text/Detail/TextMessageDetailView.swift b/SodaLive/Sources/Message/Text/Detail/TextMessageDetailView.swift new file mode 100644 index 0000000..7261f6d --- /dev/null +++ b/SodaLive/Sources/Message/Text/Detail/TextMessageDetailView.swift @@ -0,0 +1,195 @@ +// +// TextMessageDetailView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI +import Kingfisher + +struct TextMessageDetailView: View { + + @StateObject var viewModel = TextMessageDetailViewModel() + @StateObject var appState = AppState.shared + + let messageItem: TextMessageItem + let messageBox: MessageFilterTab + + let refresh: () -> Void + + func back() { + refresh() + AppState.shared.back() + } + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + switch messageBox { + case .receive: + DetailNavigationBar(title: "받은 메시지 상세") { back() } + case .sent: + DetailNavigationBar(title: "보낸 메시지 상세") { back() } + case .keep: + DetailNavigationBar(title: "저장한 메시지 상세") { back() } + } + + HStack(spacing: 13.3) { + KFImage( + URL( + string: messageBox == .sent ? + messageItem.recipientProfileImageUrl : + messageItem.senderProfileImageUrl + ) + ) + .resizable() + .scaledToFill() + .frame(width: 26.7, height: 26.7, alignment: .top) + .cornerRadius(13.3) + + Text( + messageBox == .sent ? + messageItem.recipientNickname : + messageItem.senderNickname + ) + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + .padding(.vertical, 12.7) + .frame(width: screenSize().width - 26.7) + .background(Color(hex: "1b1b1b")) + .cornerRadius(10) + + Text(messageItem.date.convertDateFormat( + from: "yyyy-MM-dd hh:mm:ss", + to: "yyyy년 MM월 dd일 E요일 HH:mm" + )) + .font(.custom(Font.medium.rawValue, size: 15)) + .foregroundColor(Color(hex: "bbbbbb")) + .padding(.top, 16.7) + + ScrollView(.vertical, showsIndicators: false) { + Text(messageItem.textMessage) + .font(.custom(Font.medium.rawValue, size: 15)) + .foregroundColor(Color(hex: "eeeeee")) + .multilineTextAlignment(.leading) + .padding(26.7) + .frame(width: screenSize().width - 26.7, alignment: .leading) + } + .frame(width: screenSize().width - 26.7) + .background(Color(hex: "222222")) + .cornerRadius(10) + .padding(.top, 10) + + Spacer() + + if messageBox == .receive { + HStack(spacing: 6.7) { + Text("답장") + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame( + width: (screenSize().width - 40) / 3, + height: 48.7 + ) + .background(Color(hex: "9970ff")) + .cornerRadius(6.7) + .onTapGesture { + AppState.shared.setAppStep(step: .writeTextMessage(userId: messageItem.senderId, nickname: messageItem.senderNickname)) + } + + Text("보관") + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "9970ff")) + .frame( + width: (screenSize().width - 40) / 3, + height: 48.7 + ) + .background(Color(hex: "1f1734")) + .cornerRadius(6.7) + .onTapGesture { + if messageItem.isKept { + viewModel.errorMessage = "이미 보관된 메시지 입니다" + viewModel.isShowPopup = true + return + } else { + viewModel.keepTextMessage() + } + } + + Text("삭제") + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "9970ff")) + .frame( + width: (screenSize().width - 40) / 3, + height: 48.7 + ) + .background(Color(hex: "1f1734")) + .cornerRadius(6.7) + .onTapGesture { + viewModel.deleteMessage { back() } + } + } + .frame(width: screenSize().width - 26.7) + .padding(.vertical, 26.7) + } else { + Text("삭제") + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "9970ff")) + .frame( + width: screenSize().width - 26.7, + height: 48.7 + ) + .background(Color(hex: "1f1734")) + .cornerRadius(6.7) + .onTapGesture { + viewModel.deleteMessage { back() } + } + .padding(.vertical, 26.7) + } + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .padding(.horizontal, 6.7) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + .onAppear { + viewModel.messageId = messageItem.messageId + } + } +} + +struct TextMessageDetailView_Previews: PreviewProvider { + static var previews: some View { + TextMessageDetailView( + messageItem: TextMessageItem( + messageId: 10, + senderId: 1, + senderNickname: "누군가", + senderProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", + recipientNickname: "테스터", + recipientProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", + textMessage: "testtesttesttest", + date: "2022-07-08 10:20:30", + isKept: false), + messageBox: .receive, + refresh: {} + ) + } +} diff --git a/SodaLive/Sources/Message/Text/Detail/TextMessageDetailViewModel.swift b/SodaLive/Sources/Message/Text/Detail/TextMessageDetailViewModel.swift new file mode 100644 index 0000000..70e5800 --- /dev/null +++ b/SodaLive/Sources/Message/Text/Detail/TextMessageDetailViewModel.swift @@ -0,0 +1,118 @@ +// +// TextMessageDetailViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Combine + +final class TextMessageDetailViewModel: ObservableObject { + + private let repository = MessageRepository() + private var subscription = Set() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var saveMessagePrice = 0 + @Published var isShowSavePopup = false + + var messageId = 0 + + func deleteMessage(onSuccess: @escaping () -> Void) { + if messageId <= 0 { + errorMessage = "메시지를 삭제하지 못했습니다\n잠시 후 다시 시도해 주세요." + isShowPopup = true + return + } + + isLoading = true + + repository.deleteMessage(messageId: messageId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.errorMessage = "삭제되었습니다." + self.isShowPopup = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + onSuccess() + } + } else { + if let message = decoded.message { + self.errorMessage = message + self.isShowPopup = true + } else { + self.errorMessage = "메시지를 보관하지 못했습니다.\n잠시 후 다시 시도해 주세요." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "메시지를 보관하지 못했습니다.\n잠시 후 다시 시도해 주세요." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func keepTextMessage() { + if messageId <= 0 { + errorMessage = "메시지를 저장하지 못했습니다\n잠시 후 다시 시도해 주세요." + isShowPopup = true + return + } + + isLoading = true + repository.keepTextMessage(messageId: messageId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.errorMessage = "보관되었습니다." + self.isShowPopup = true + } else { + if let message = decoded.message { + self.errorMessage = message + self.isShowPopup = true + } else { + self.errorMessage = "메시지를 보관하지 못했습니다.\n잠시 후 다시 시도해 주세요." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "메시지를 보관하지 못했습니다.\n잠시 후 다시 시도해 주세요." + self.isShowPopup = true + } + } + .store(in: &subscription) + } +} diff --git a/SodaLive/Sources/Message/Text/GetTextMessageResponse.swift b/SodaLive/Sources/Message/Text/GetTextMessageResponse.swift new file mode 100644 index 0000000..6e12e97 --- /dev/null +++ b/SodaLive/Sources/Message/Text/GetTextMessageResponse.swift @@ -0,0 +1,25 @@ +// +// GetTextMessageResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation + +struct GetTextMessageResponse: Decodable { + let totalCount: Int + let items: [TextMessageItem] +} + +struct TextMessageItem: Decodable, Hashable { + let messageId: Int + let senderId: Int + let senderNickname: String + let senderProfileImageUrl: String + let recipientNickname: String + let recipientProfileImageUrl: String + let textMessage: String + let date: String + let isKept: Bool +} diff --git a/SodaLive/Sources/Message/Text/TextMessageItemView.swift b/SodaLive/Sources/Message/Text/TextMessageItemView.swift new file mode 100644 index 0000000..dac46ee --- /dev/null +++ b/SodaLive/Sources/Message/Text/TextMessageItemView.swift @@ -0,0 +1,66 @@ +// +// TextMessageItemView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI +import Kingfisher + +struct TextMessageItemView: View { + + let item: TextMessageItem + + var body: some View { + + let nickname = item.recipientNickname == UserDefaults.string(forKey: .nickname) ? item.senderNickname : item.recipientNickname + + let profileUrl = item.recipientNickname == UserDefaults.string(forKey: .nickname) ? item.senderProfileImageUrl : item.recipientProfileImageUrl + + HStack(spacing: 13.3) { + KFImage(URL(string: profileUrl)) + .resizable() + .scaledToFill() + .frame(width: 46.7, height: 46.7, alignment: .top) + .cornerRadius(23.4) + .clipped() + + VStack(alignment: .leading, spacing: 4.7) { + Text(nickname) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Text(item.textMessage) + .font(.custom(Font.light.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + .multilineTextAlignment(.leading) + .lineLimit(2) + } + + Spacer() + + Text(item.date) + .font(.custom(Font.light.rawValue, size: 12)) + .foregroundColor(Color(hex: "525252")) + } + } +} + +struct TextMessageItemView_Previews: PreviewProvider { + static var previews: some View { + TextMessageItemView( + item: TextMessageItem( + messageId: 18, + senderId: 19, + senderNickname: "user8", + senderProfileImageUrl: "https://test-cf.yozm.day/profile/default_profile.png", + recipientNickname: "uset7", + recipientProfileImageUrl: "https://test-cf.yozm.day/profile/default_profile.png", + textMessage: "ㅅㅅㅅㅅㅅㅅㅅㅅ러러러라라가가각개가사러", + date: "2022-05-23 16:15:22", + isKept: false + ) + ) + } +} diff --git a/SodaLive/Sources/Message/Text/TextMessageView.swift b/SodaLive/Sources/Message/Text/TextMessageView.swift new file mode 100644 index 0000000..ef7234c --- /dev/null +++ b/SodaLive/Sources/Message/Text/TextMessageView.swift @@ -0,0 +1,92 @@ +// +// TextMessageView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI + +struct TextMessageView: View { + + @StateObject var viewModel = TextMessageViewModel() + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 13.3) { + + MessageFilterTabView(currentFilterTab: $viewModel.currentFilter) + .padding(.top, 20) + + ScrollView(.vertical, showsIndicators: false) { + if viewModel.items.count > 0 { + LazyVStack(spacing: 26.7) { + ForEach(viewModel.items, id: \.self) { item in + TextMessageItemView(item: item) + .frame(width: screenSize().width - 26.7) + .contentShape(Rectangle()) + .onTapGesture { + AppState.shared.setAppStep( + step: .textMessageDetail( + messageItem: item, + messageBox: viewModel.currentFilter, + refresh: { + viewModel.page = 1 + switch viewModel.currentFilter { + case .receive: + viewModel.getReceivedTextMessage() + case .sent: + viewModel.getSentTextMessage() + case .keep: + viewModel.getKeepTextMessage() + } + } + ) + ) + } + } + } + .padding(.top, 26.7) + } else { + VStack(spacing: 6.7) { + Image("ic_no_item") + .resizable() + .frame(width: 60, height: 60) + + Text("메시지가 없습니다.\n친구들과 소통해보세요!") + .multilineTextAlignment(.center) + .font(.custom(Font.medium.rawValue, size: 10.7)) + .foregroundColor(Color(hex: "bbbbbb")) + } + .frame(width: screenSize().width - 26.7, height: screenSize().height / 2) + .background(Color(hex: "2b2635")) + .cornerRadius(4.7) + } + } + } + + Image("ic_make_message") + .resizable() + .padding(13.3) + .frame(width: 53.3, height: 53.3) + .background(Color(hex: "9970ff")) + .cornerRadius(26.7) + .padding(.bottom, 33.3) + .padding(.trailing, 6.7) + .onTapGesture { + AppState.shared.setAppStep(step: .writeTextMessage(userId: nil, nickname: nil)) + } + } + .onAppear { + viewModel.getReceivedTextMessage() + } + } + } +} + +struct TextMessageView_Previews: PreviewProvider { + static var previews: some View { + TextMessageView() + } +} diff --git a/SodaLive/Sources/Message/Text/TextMessageViewModel.swift b/SodaLive/Sources/Message/Text/TextMessageViewModel.swift new file mode 100644 index 0000000..4bdda82 --- /dev/null +++ b/SodaLive/Sources/Message/Text/TextMessageViewModel.swift @@ -0,0 +1,234 @@ +// +// TextMessageViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Combine + +final class TextMessageViewModel: ObservableObject { + private let repository = MessageRepository() + private var subscription = Set() + + @Published var items = [TextMessageItem]() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var currentFilter: MessageFilterTab = .receive { + willSet(newVal) { + page = 1 + + switch newVal { + case .receive: + getReceivedTextMessage() + + case .sent: + getSentTextMessage() + + case .keep: + getKeepTextMessage() + } + } + } + + @Published var message: String = "" + @Published var recipientNickname: String = "" + @Published var recipientId = 0 + + @Published var sendText = "메시지 보내기" + + let placeholder = "내용을 입력해 주세요." + + var page = 1 + private let size = 10 + + func write() { + let textMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) != placeholder ? message : "" + if recipientId <= 0 { + errorMessage = "받는 사람을 선택해 주세요." + isShowPopup = true + return + } + + if textMessage.count < 10 { + errorMessage = "10글자 이상 입력해 주세요." + isShowPopup = true + return + } + + if !isLoading { + isLoading = true + let request = SendTextMessageRequest(recipientId: recipientId, textMessage: textMessage) + repository.sendTextMessage(request: request) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.errorMessage = "메시지 전송이 완료되었습니다." + self.isShowPopup = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + AppState.shared.back() + } + } 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 getReceivedTextMessage() { + if page == 1 { + items.removeAll() + } + + isLoading = true + + repository + .getReceivedTextMessage(page: page, size: size) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.items.append(contentsOf: data.items) + } 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 getSentTextMessage() { + if page == 1 { + items.removeAll() + } + + isLoading = true + + repository + .getSentTextMessage(page: page, size: size) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.items.append(contentsOf: data.items) + } 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 getKeepTextMessage() { + if page == 1 { + items.removeAll() + } + + isLoading = true + + repository + .getKeepTextMessage(page: page, size: size) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.items.append(contentsOf: data.items) + } 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/Message/Text/Write/TextMessageWriteView.swift b/SodaLive/Sources/Message/Text/Write/TextMessageWriteView.swift new file mode 100644 index 0000000..3457ee0 --- /dev/null +++ b/SodaLive/Sources/Message/Text/Write/TextMessageWriteView.swift @@ -0,0 +1,153 @@ +// +// TextMessageWriteView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI + +struct TextMessageWriteView: View { + + @StateObject var viewModel = TextMessageViewModel() + @StateObject var appState = AppState.shared + + var replySenderId: Int? = nil + var replySenderNickname: String? = nil + + @State var isShowSearchUser = false + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("취소") + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "9970ff").opacity(0)) + + Spacer() + + Text("새로운 메시지") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Text("취소") + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "9970ff")) + .onTapGesture { + AppState.shared.back() + } + } + .padding(.horizontal, 13.3) + .frame(height: 50) + + VStack(spacing: 0) { + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0)) + + Spacer() + + HStack(spacing: 13.3) { + Text("받는 사람") + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "777777")) + + Text(viewModel.recipientNickname) + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + if replySenderId == nil && replySenderNickname == nil { + Image("btn_plus_round") + .resizable() + .frame(width: 27, height: 27) + .onTapGesture { + isShowSearchUser = true + } + } + } + .padding(.horizontal, 13.3) + + Spacer() + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.5)) + } + .frame(height: 50) + + TextViewWrapper( + text: $viewModel.message, + placeholder: viewModel.placeholder, + textColorHex: "eeeeee", + backgroundColorHex: "333333" + ) + .frame(width: screenSize().width - 26.7, height: 150) + .cornerRadius(6.7) + .overlay( + RoundedRectangle(cornerRadius: 6.7) + .stroke(Color(hex: "9970ff"), lineWidth: 1.3) + ) + .padding(.top, 13.3) + + Spacer() + + Text(viewModel.sendText) + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(width: screenSize().width - 26.7, height: 48.7) + .background(Color(hex: "9970ff")) + .cornerRadius(6.7) + .padding(.bottom, 13.3) + .onTapGesture { + hideKeyboard() + viewModel.write() + } + } + + if isShowSearchUser { + SelectRecipientView(isShowing: $isShowSearchUser) { + viewModel.recipientId = $0.id + viewModel.recipientNickname = $0.nickname + } + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .padding(.horizontal, 6.7) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + .onAppear { + UITextView.appearance().backgroundColor = .clear + + if let replySenderId = replySenderId, let replySenderNickname = replySenderNickname { + viewModel.recipientId = replySenderId + viewModel.recipientNickname = replySenderNickname + } + } + } +} + +struct TextMessageWriteView_Previews: PreviewProvider { + static var previews: some View { + TextMessageWriteView() + } +} diff --git a/SodaLive/Sources/Message/Voice/GetVoiceMessageResponse.swift b/SodaLive/Sources/Message/Voice/GetVoiceMessageResponse.swift new file mode 100644 index 0000000..0a535fd --- /dev/null +++ b/SodaLive/Sources/Message/Voice/GetVoiceMessageResponse.swift @@ -0,0 +1,25 @@ +// +// GetVoiceMessageResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation + +struct GetVoiceMessageResponse: Decodable { + let totalCount: Int + let items: [VoiceMessageItem] +} + +struct VoiceMessageItem: Decodable, Hashable { + let messageId: Int + let senderId: Int + let senderNickname: String + let senderProfileImageUrl: String + let recipientNickname: String + let recipientProfileImageUrl: String + let voiceMessageUrl: String + let date: String + let isKept: Bool +} diff --git a/SodaLive/Sources/Message/Voice/SoundManager.swift b/SodaLive/Sources/Message/Voice/SoundManager.swift new file mode 100644 index 0000000..3fdd49e --- /dev/null +++ b/SodaLive/Sources/Message/Voice/SoundManager.swift @@ -0,0 +1,164 @@ +// +// SoundManager.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import AVKit + +class SoundManager: NSObject, ObservableObject { + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + @Published var onClose = false + + @Published var isPlaying = false + @Published var isRecording = false + @Published var duration: TimeInterval = 0 + + var player: AVAudioPlayer! + var audioRecorder: AVAudioRecorder! + + var startTimer: (() -> Void)? + var stopTimer: (() -> Void)? + + func prepareRecording() { + isLoading = true + + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.playAndRecord, mode: .default) + try audioSession.setActive(true) + audioSession.requestRecordPermission() { [weak self] allowed in + DispatchQueue.main.async { + if !allowed { + self?.errorMessage = "권한을 허용하지 않으시면 음성메시지 서비스를 이용하실 수 없습니다." + self?.isShowPopup = true + self?.onClose = true + } + } + } + } catch { + errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." + isShowPopup = true + onClose = true + } + + isLoading = false + } + + func startRecording() { + let settings = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 12000, + AVNumberOfChannelsKey: 1, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue + ] + + do { + audioRecorder = try AVAudioRecorder(url: getAudioFileURL(), settings: settings) + audioRecorder.record() + + if let startTimer = startTimer { + startTimer() + } + isRecording = true + } catch { + errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." + isShowPopup = true + } + } + + func stopRecording() { + audioRecorder.stop() + audioRecorder = nil + isRecording = false + + if let stopTimer = stopTimer { + stopTimer() + } + + prepareForPlay() + } + + func getRecorderCurrentTime() -> TimeInterval { + return audioRecorder.currentTime + } + + func prepareForPlay(_ url: URL? = nil) { + isLoading = true + + DispatchQueue.main.async { + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.playback, mode: .default) + + if let url = url { + self.player = try AVAudioPlayer(data: Data(contentsOf: url)) + } else { + self.player = try AVAudioPlayer(contentsOf: self.getAudioFileURL()) + } + + self.player?.volume = 1 + self.player?.delegate = self + self.player?.prepareToPlay() + + self.duration = self.player.duration + } catch { + self.errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." + self.isShowPopup = true + } + + self.isLoading = false + } + } + + func playAudio() { + player?.play() + + isPlaying = player.isPlaying + if let startTimer = startTimer { + startTimer() + } + } + + func stopAudio() { + player.stop() + player.currentTime = 0 + isPlaying = player.isPlaying + if let stopTimer = stopTimer { + stopTimer() + } + } + + func getPlayerCurrentTime() -> TimeInterval { + return player.currentTime + } + + func deleteAudioFile() { + do { + try FileManager.default.removeItem(at: getAudioFileURL()) + duration = 0 + } catch { + errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." + isShowPopup = true + } + } + + func getAudioFileURL() -> URL { + return getDocumentsDirectory().appendingPathComponent("recording.m4a") + } + + private func getDocumentsDirectory() -> URL { + let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + return paths[0] + } +} + +extension SoundManager: AVAudioPlayerDelegate { + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + stopAudio() + } +} diff --git a/SodaLive/Sources/Message/Voice/VoiceMessageItemView.swift b/SodaLive/Sources/Message/Voice/VoiceMessageItemView.swift new file mode 100644 index 0000000..daf729f --- /dev/null +++ b/SodaLive/Sources/Message/Voice/VoiceMessageItemView.swift @@ -0,0 +1,187 @@ +// +// VoiceMessageItemView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI +import Kingfisher + +struct VoiceMessageItemView: View { + + let index: Int + let item: VoiceMessageItem + let currentFilter: MessageFilterTab + let soundManager: SoundManager + + @Binding var openPlayerItemIndex: Int + + let onClickSave: () -> Void + let onClickReply: () -> Void + let onClickDelete: () -> Void + + @State var progress: TimeInterval = 0 + @State var timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect() + + var body: some View { + + let nickname = item.recipientNickname == UserDefaults.string(forKey: .nickname) ? item.senderNickname : item.recipientNickname + + let profileUrl = item.recipientNickname == UserDefaults.string(forKey: .nickname) ? item.senderProfileImageUrl : item.recipientProfileImageUrl + + VStack(spacing: 10) { + HStack(spacing: 0) { + KFImage(URL(string: profileUrl)) + .resizable() + .scaledToFill() + .frame(width: 46.7, height: 46.7, alignment: .top) + .clipped() + .cornerRadius(23.4) + + Text(nickname) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.leading, 13.3) + + Spacer() + + Text(item.date) + .font(.custom(Font.light.rawValue, size: 12)) + .foregroundColor(Color(hex: "525252")) + } + .contentShape(Rectangle()) + .onTapGesture { + openPlayerItemIndex = openPlayerItemIndex == index ? -1 : index + } + + if openPlayerItemIndex == index { + VStack(spacing: 0) { + ProgressView(value: progress, total: soundManager.duration) + .progressViewStyle(LinearProgressViewStyle(tint: Color(hex: "9970ff"))) + .padding(.horizontal, 13.3) + + HStack(spacing: 0) { + Text("00:00") + .font(.custom(Font.medium.rawValue, size: 10.7)) + .foregroundColor(Color(hex: "bbbbbb")) + + Spacer() + + Text("\(secondsToMinutesSeconds(seconds: Int(soundManager.duration)))") + .font(.custom(Font.medium.rawValue, size: 10.7)) + .foregroundColor(Color(hex: "bbbbbb")) + } + .padding(.horizontal, 13.3) + .padding(.top, 6.7) + + HStack(spacing: 0) { + + Image("ic_save") + .resizable() + .frame( + width: currentFilter == .receive ? 27 : 22, + height: currentFilter == .receive ? 27 : 22 + ) + .opacity(currentFilter == .receive ? 1 : 0) + .onTapGesture { + if currentFilter == .receive { + onClickSave() + } + } + + Spacer() + + Image(soundManager.isPlaying ? "btn_bar_stop": "btn_bar_play") + .resizable() + .frame(width: 40, height: 40) + .onTapGesture { + if soundManager.isPlaying { + soundManager.stopAudio() + } else { + soundManager.playAudio() + } + } + + Spacer() + + if currentFilter == .receive { + Image("ic_mic_paint") + .resizable() + .frame(width: 27, height: 27) + .onTapGesture { onClickReply() } + } + + if currentFilter == .sent || currentFilter == .keep { + Image(systemName: "trash.fill") + .resizable() + .frame(width: 22, height: 22) + .onTapGesture { onClickDelete() } + } + } + .padding(.top, 24.3) + .padding(.horizontal, 13.3) + } + .padding(.vertical, 20) + .background(Color(hex: "9970ff").opacity(0.2)) + .cornerRadius(6.7) + .onAppear { + soundManager.startTimer = startTimer + soundManager.stopTimer = stopTimer + soundManager.prepareForPlay(URL(string: item.voiceMessageUrl)!) + } + .onDisappear { + soundManager.stopAudio() + } + } + } + .frame(width: screenSize().width - 26.7) + .background(Color.black) + .onAppear { + stopTimer() + } + .onReceive(timer) { _ in + self.progress = soundManager.getPlayerCurrentTime() + } + } + + private func secondsToMinutesSeconds(seconds: Int) -> String { + let minute = String(format: "%02d", seconds / 60) + let second = String(format: "%02d", seconds % 60) + + return "\(minute):\(second)" + } + + private func startTimer() { + timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect() + } + + private func stopTimer() { + timer.upstream.connect().cancel() + } +} + +struct VoiceMessageItemView_Previews: PreviewProvider { + static var previews: some View { + VoiceMessageItemView( + index: 0, + item: VoiceMessageItem( + messageId: 24, + senderId: 13, + senderNickname: "user5", + senderProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", + recipientNickname: "user8", + recipientProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", + voiceMessageUrl: "", + date: "2022-07-02 01:42:43", + isKept: false + ), + currentFilter: .keep, + soundManager: SoundManager(), + openPlayerItemIndex: .constant(0), + onClickSave: {}, + onClickReply: {}, + onClickDelete: {} + ) + } +} diff --git a/SodaLive/Sources/Message/Voice/VoiceMessageView.swift b/SodaLive/Sources/Message/Voice/VoiceMessageView.swift new file mode 100644 index 0000000..eece48e --- /dev/null +++ b/SodaLive/Sources/Message/Voice/VoiceMessageView.swift @@ -0,0 +1,215 @@ +// +// VoiceMessageView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI + +struct VoiceMessageView: View { + + @StateObject var viewModel = VoiceMessageViewModel() + @StateObject var soundManager = SoundManager() + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 13.3) { + + MessageFilterTabView(currentFilterTab: $viewModel.currentFilter) + .padding(.top, 20) + + ScrollView(.vertical, showsIndicators: false) { + if viewModel.items.count > 0 { + VStack(spacing: 26.7) { + ForEach(0..() + + @Published var items = [VoiceMessageItem]() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var saveMessagePrice = 0 + @Published var isShowSavePopup = false + + @Published var currentFilter: MessageFilterTab = .receive { + didSet { + refresh() + } + } + + @Published var recipientNickname: String = "" + @Published var recipientId = 0 + + @Published var sendText = "메시지 보내기" + + @Published var selectedMessageId = -1 + @Published var openPlayerItemIndex = -1 + + @Published var recordMode = RecordMode.RECORD + + enum RecordMode { + case RECORD, PLAY + } + + private var isLast = false + private var page = 1 + private let size = 10 + + func refresh() { + page = 1 + isLast = false + selectedMessageId = -1 + openPlayerItemIndex = -1 + + loadMessage() + } + + func loadMessage() { + switch currentFilter { + case .receive: + getReceivedVoiceMessage() + + case .sent: + getSentVoiceMessage() + + case .keep: + getKeepVoiceMessage() + } + } + + func deleteMessage() { + if selectedMessageId <= 0 { + errorMessage = "메시지를 삭제하지 못했습니다\n잠시 후 다시 시도해 주세요." + isShowPopup = true + return + } + + isLoading = true + + repository.deleteMessage(messageId: selectedMessageId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.errorMessage = "삭제되었습니다." + self.isShowPopup = true + self.refresh() + } else { + if let message = decoded.message { + self.errorMessage = message + self.isShowPopup = true + } else { + self.errorMessage = "메시지를 보관하지 못했습니다.\n잠시 후 다시 시도해 주세요." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "메시지를 보관하지 못했습니다.\n잠시 후 다시 시도해 주세요." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func keepTextMessage() { + if selectedMessageId <= 0 { + errorMessage = "메시지를 저장하지 못했습니다\n잠시 후 다시 시도해 주세요." + isShowPopup = true + return + } + + isLoading = true + repository.keepTextMessage(messageId: selectedMessageId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.errorMessage = "보관되었습니다." + self.isShowPopup = true + self.refresh() + } else { + if let message = decoded.message { + self.errorMessage = message + self.isShowPopup = true + } else { + self.errorMessage = "메시지를 보관하지 못했습니다.\n잠시 후 다시 시도해 주세요." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "메시지를 보관하지 못했습니다.\n잠시 후 다시 시도해 주세요." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func write(soundData: Data, onSuccess: @escaping () -> Void) { + if recipientId <= 0 { + errorMessage = "받는 사람을 선택해 주세요." + isShowPopup = true + return + } + + if !isLoading { + isLoading = true + let request = SendVoiceMessageRequest(recipientId: recipientId) + + var multipartData = [MultipartFormData]() + + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + let jsonData = try? encoder.encode(request) + if let jsonData = jsonData { + multipartData.append( + MultipartFormData( + provider: .data(soundData), + name: "voiceMessageFile", + fileName: "\(UUID().uuidString)_\(Date().timeIntervalSince1970 * 1000).m4a", + mimeType: "audio/m4a") + ) + multipartData.append(MultipartFormData(provider: .data(jsonData), name: "request")) + + repository.sendVoiceMessage(parameters: multipartData) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + onSuccess() + self.errorMessage = "메시지 전송이 완료되었습니다." + self.isShowPopup = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + AppState.shared.back() + } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "음성메시지를 전송하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "음성메시지를 전송하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } else { + self.errorMessage = "음성메시지를 전송하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + self.isLoading = false + } + } + } + + private func getReceivedVoiceMessage() { + if page == 1 { + items.removeAll() + } + + isLoading = true + + repository + .getReceivedVoiceMessage(page: page, size: size) + .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.count <= 0 { + self.isLast = true + } else { + self.items.append(contentsOf: data.items) + self.page += 1 + } + } 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 getSentVoiceMessage() { + if page == 1 { + items.removeAll() + } + + isLoading = true + + repository + .getSentVoiceMessage(page: page, size: size) + .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.count <= 0 { + self.isLast = true + } else { + self.items.append(contentsOf: data.items) + self.page += 1 + } + } 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 getKeepVoiceMessage() { + if page == 1 { + items.removeAll() + } + + isLoading = true + + repository + .getKeepVoiceMessage(page: page, size: size) + .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.count <= 0 { + self.isLast = true + } else { + self.items.append(contentsOf: data.items) + self.page += 1 + } + } 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/Message/Voice/Write/VoiceMessageWriteView.swift b/SodaLive/Sources/Message/Voice/Write/VoiceMessageWriteView.swift new file mode 100644 index 0000000..c5d2c80 --- /dev/null +++ b/SodaLive/Sources/Message/Voice/Write/VoiceMessageWriteView.swift @@ -0,0 +1,337 @@ +// +// VoiceMessageWriteView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI + +struct VoiceMessageWriteView: View { + + @StateObject var viewModel = VoiceMessageViewModel() + @StateObject var soundManager = SoundManager() + @StateObject var appState = AppState.shared + + var replySenderId: Int? = nil + var replySenderNickname: String? = nil + let onRefresh: () -> Void + + @State var isShowSearchUser = false + @State var timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect() + + @State var progress: TimeInterval = 0 + + var body: some View { + ZStack { + Color.black.opacity(0.7) + .ignoresSafeArea() + .onTapGesture { + hideView() + } + + GeometryReader { proxy in + VStack { + Spacer() + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("음성메시지") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.white) + + Spacer() + + Image("ic_close_white") + .resizable() + .frame(width: 20, height: 20) + .onTapGesture { + hideView() + } + } + .padding(.horizontal, 26.7) + .padding(.top, 26.7) + + HStack(spacing: 13.3) { + Image("img_thumb_default") + .resizable() + .frame(width: 46.7, height: 46.7) + + VStack(alignment: .leading, spacing: 10) { + Text("TO.") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Text( + viewModel.recipientNickname.count > 0 ? + viewModel.recipientNickname : + "받는 사람" + ) + .font( + .custom( + viewModel.recipientNickname.count > 0 ? + Font.bold.rawValue : + Font.light.rawValue, + size: 16.7 + ) + ) + .foregroundColor( + Color( + hex: + viewModel.recipientNickname.count > 0 ? + "eeeeee" : + "bbbbbb" + ) + ) + } + + Spacer() + + if replySenderId == nil && replySenderNickname == nil { + Image("btn_plus_round") + .resizable() + .frame(width: 27, height: 27) + } + + + } + .padding(13.3) + .background(Color(hex: "9970ff").opacity(0.2)) + .cornerRadius(6.7) + .padding(.horizontal, 13.3) + .padding(.top, 26.7) + .onTapGesture { + if replySenderId == nil && replySenderNickname == nil { + isShowSearchUser = true + } + } + + Text(secondsToHoursMinutesSeconds(seconds:Int(progress))) + .font(.custom(Font.light.rawValue, size: 33.3)) + .foregroundColor(.white) + .padding(.top, 81) + + switch viewModel.recordMode { + case .RECORD: + Image(soundManager.isRecording ? "ic_record_stop" : "ic_record") + .resizable() + .frame(width: 70, height: 70) + .padding(.vertical, 52.3) + .onTapGesture { + if viewModel.recipientId <= 0 { + viewModel.errorMessage = "받는 사람을 선택해 주세요." + viewModel.isShowPopup = true + } else { + progress = 0 + if !soundManager.isRecording { + soundManager.startRecording() + } else { + soundManager.stopRecording() + viewModel.recordMode = .PLAY + } + } + } + + case .PLAY: + VStack(spacing: 0) { + HStack(spacing: 0) { + Spacer() + + Text("삭제") + .font(.custom(Font.medium.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "bbbbbb").opacity(0)) + + Spacer() + + Image( + !soundManager.isPlaying ? + "ic_record_play" : + "ic_record_pause" + ) + .onTapGesture { + progress = 0 + if !soundManager.isPlaying { + soundManager.playAudio() + } else { + soundManager.stopAudio() + } + } + + Spacer() + + Text("삭제") + .font(.custom(Font.medium.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "bbbbbb")) + .onTapGesture { + soundManager.stopAudio() + soundManager.deleteAudioFile() + viewModel.recordMode = .RECORD + } + + Spacer() + } + .padding(.top, 90) + + HStack(spacing: 13.3) { + Text("다시 녹음") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "9970ff")) + .frame(width: (proxy.size.width - 40) / 3, height: 50) + .background(Color(hex: "9970ff").opacity(0.2)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color(hex: "9970ff"), lineWidth: 1.3) + ) + .onTapGesture { + soundManager.stopAudio() + soundManager.deleteAudioFile() + viewModel.recordMode = .RECORD + } + + Text(viewModel.sendText) + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.white) + .frame(width: (proxy.size.width - 40) * 2 / 3, height: 50) + .background(Color(hex: "9970ff")) + .cornerRadius(10) + .onTapGesture { + do { + let soundData = try Data(contentsOf: soundManager.getAudioFileURL()) + viewModel.write (soundData: soundData) { + soundManager.deleteAudioFile() + onRefresh() + } + } catch { + viewModel.errorMessage = "음성메시지를 전송하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + viewModel.isShowPopup = true + } + } + } + .padding(.top, 26.7) + .padding(.bottom, 13.3) + .padding(.horizontal, 13.3) + } + } + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(Color(hex: "222222")) + .frame(width: proxy.size.width, height: 15.3) + } + } + .background(Color(hex: "222222")) + .cornerRadius(16.7, corners: [.topLeft, .topRight]) + } + .edgesIgnoringSafeArea(.bottom) + } + + if isShowSearchUser { + SelectRecipientView(isShowing: $isShowSearchUser) { + viewModel.recipientId = $0.id + viewModel.recipientNickname = $0.nickname + } + } + + if viewModel.isLoading || soundManager.isLoading { + LoadingView() + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .padding(.horizontal, 6.7) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + .popup(isPresented: $soundManager.isShowPopup, type: .toast, position: .top, autohideIn: 1) { + GeometryReader { geo in + HStack { + Spacer() + Text(soundManager.errorMessage) + .padding(.vertical, 13.3) + .padding(.horizontal, 6.7) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + .onDisappear { + if soundManager.onClose { + hideView() + } + } + } + } + .onAppear { + stopTimer() + soundManager.startTimer = startTimer + soundManager.stopTimer = stopTimer + soundManager.prepareRecording() + + UITextView.appearance().backgroundColor = .clear + + if let replySenderId = replySenderId, let replySenderNickname = replySenderNickname { + viewModel.recipientId = replySenderId + viewModel.recipientNickname = replySenderNickname + } + } + .onReceive(timer) { _ in + switch viewModel.recordMode { + case .RECORD: + progress = soundManager.getRecorderCurrentTime() + + case .PLAY: + progress = soundManager.getPlayerCurrentTime() + } + } + } + + private func hideView() { + if isShowSearchUser { + isShowSearchUser = false + } + + soundManager.deleteAudioFile() + onRefresh() + AppState.shared.back() + } + + private func secondsToHoursMinutesSeconds(seconds: Int) -> String { + let hour = String(format: "%02d", seconds / 3600) + let minute = String(format: "%02d", (seconds % 3600) / 60) + let second = String(format: "%02d", (seconds % 3600) % 60) + + return "\(hour):\(minute):\(second)" + } + + private func startTimer() { + timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() + } + + private func stopTimer() { + timer.upstream.connect().cancel() + } +} + +struct VoiceMessageWriteView_Previews: PreviewProvider { + static var previews: some View { + VoiceMessageWriteView(onRefresh: {}) + } +} diff --git a/SodaLive/Sources/User/UserApi.swift b/SodaLive/Sources/User/UserApi.swift index e96dfcd..afa3eed 100644 --- a/SodaLive/Sources/User/UserApi.swift +++ b/SodaLive/Sources/User/UserApi.swift @@ -12,6 +12,7 @@ enum UserApi { case login(request: LoginRequest) case signUp(parameters: [MultipartFormData]) case findPassword(request: ForgotPasswordRequest) + case searchUser(nickname: String) } extension UserApi: TargetType { @@ -29,6 +30,9 @@ extension UserApi: TargetType { case .findPassword: return "/forgot-password" + + case .searchUser: + return "/member/search" } } @@ -36,6 +40,9 @@ extension UserApi: TargetType { switch self { case .login, .signUp, .findPassword: return .post + + case .searchUser: + return .get } } @@ -49,6 +56,9 @@ extension UserApi: TargetType { case .findPassword(let request): return .requestJSONEncodable(request) + + case .searchUser(let nickname): + return .requestParameters(parameters: ["nickname" : nickname], encoding: URLEncoding.queryString) } } diff --git a/SodaLive/Sources/User/UserRepository.swift b/SodaLive/Sources/User/UserRepository.swift index 010d402..729d40a 100644 --- a/SodaLive/Sources/User/UserRepository.swift +++ b/SodaLive/Sources/User/UserRepository.swift @@ -24,4 +24,8 @@ final class UserRepository { func findPassword(email: String) -> AnyPublisher { return api.requestPublisher(.findPassword(request: ForgotPasswordRequest(email: email))) } + + func searchUser(nickname: String) -> AnyPublisher { + return api.requestPublisher(.searchUser(nickname: nickname)) + } }