메시지 - 리스트, 쓰기, 상세 페이지 추가
This commit is contained in:
		
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/btn_plus_round.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/btn_plus_round.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/btn_plus_round.imageset/btn_plus_round.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/btn_plus_round.imageset/btn_plus_round.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.0 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_make_message.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_make_message.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_make_message.imageset/ic_make_message.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_make_message.imageset/ic_make_message.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 571 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_make_voice.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_make_voice.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_make_voice.imageset/ic_make_voice.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_make_voice.imageset/ic_make_voice.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 742 B | 
| @@ -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) | ||||
| } | ||||
|   | ||||
							
								
								
									
										100
									
								
								SodaLive/Sources/Common/TextViewWrapper.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								SodaLive/Sources/Common/TextViewWrapper.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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<String> | ||||
|         var placeholder: String | ||||
|         var textColorHex: String | ||||
|          | ||||
|         init(_ text: Binding<String>, 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" | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 | ||||
|         } | ||||
|     } | ||||
|      | ||||
|   | ||||
| @@ -16,4 +16,8 @@ final class LiveRepository { | ||||
|     func roomList(request: GetRoomListRequest) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.roomList(request: request)) | ||||
|     } | ||||
|      | ||||
|     func recentVisitRoomUsers() -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.recentVisitRoomUsers) | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										12
									
								
								SodaLive/Sources/Message/KeepMessageRequest.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								SodaLive/Sources/Message/KeepMessageRequest.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| // | ||||
| //  KeepMessageRequest.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/10. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct KeepMessageRequest: Encodable { | ||||
|     let container = "ios" | ||||
| } | ||||
							
								
								
									
										128
									
								
								SodaLive/Sources/Message/MessageApi.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								SodaLive/Sources/Message/MessageApi.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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))"] | ||||
|     } | ||||
| } | ||||
							
								
								
									
										12
									
								
								SodaLive/Sources/Message/MessageFilterTab.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								SodaLive/Sources/Message/MessageFilterTab.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| // | ||||
| //  MessageFilterTab.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/10. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| enum MessageFilterTab { | ||||
|     case receive, sent, keep | ||||
| } | ||||
							
								
								
									
										77
									
								
								SodaLive/Sources/Message/MessageFilterTabView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								SodaLive/Sources/Message/MessageFilterTabView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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)) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										59
									
								
								SodaLive/Sources/Message/MessageRepository.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								SodaLive/Sources/Message/MessageRepository.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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<MessageApi>() | ||||
|      | ||||
|     func getReceivedTextMessage(page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getReceivedTextMessage(page: page, size: size)) | ||||
|     } | ||||
|      | ||||
|     func getSentTextMessage(page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getSentTextMessage(page: page, size: size)) | ||||
|     } | ||||
|      | ||||
|     func getKeepTextMessage(page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getKeepTextMessage(page: page, size: size)) | ||||
|     } | ||||
|      | ||||
|     func sendTextMessage(request: SendTextMessageRequest) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.sendTextMessage(request:request)) | ||||
|     } | ||||
|      | ||||
|     func keepTextMessage(messageId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.keepTextMessage(messageId: messageId)) | ||||
|     } | ||||
|      | ||||
|     func getReceivedVoiceMessage(page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getReceivedVoiceMessage(page: page, size: size)) | ||||
|     } | ||||
|      | ||||
|     func getSentVoiceMessage(page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getSentVoiceMessage(page: page, size: size)) | ||||
|     } | ||||
|      | ||||
|     func getKeepVoiceMessage(page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getKeepVoiceMessage(page: page, size: size)) | ||||
|     } | ||||
|      | ||||
|     func sendVoiceMessage(parameters: [MultipartFormData]) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.sendVoiceMessage(parameters: parameters)) | ||||
|     } | ||||
|      | ||||
|     func keepVoiceMessage(messageId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.keepVoiceMessage(messageId: messageId)) | ||||
|     } | ||||
|      | ||||
|     func deleteMessage(messageId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.deleteMessage(messageId: messageId)) | ||||
|     } | ||||
| } | ||||
| @@ -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")) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										17
									
								
								SodaLive/Sources/Message/MessageViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								SodaLive/Sources/Message/MessageViewModel.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
| @@ -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 } | ||||
|     } | ||||
| } | ||||
| @@ -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<AnyCancellable>() | ||||
|      | ||||
|     @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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										19
									
								
								SodaLive/Sources/Message/SendMessageRequest.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								SodaLive/Sources/Message/SendMessageRequest.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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" | ||||
| } | ||||
							
								
								
									
										195
									
								
								SodaLive/Sources/Message/Text/Detail/TextMessageDetailView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								SodaLive/Sources/Message/Text/Detail/TextMessageDetailView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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: {} | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -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<AnyCancellable>() | ||||
|      | ||||
|     @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) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										25
									
								
								SodaLive/Sources/Message/Text/GetTextMessageResponse.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								SodaLive/Sources/Message/Text/GetTextMessageResponse.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										66
									
								
								SodaLive/Sources/Message/Text/TextMessageItemView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								SodaLive/Sources/Message/Text/TextMessageItemView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										92
									
								
								SodaLive/Sources/Message/Text/TextMessageView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								SodaLive/Sources/Message/Text/TextMessageView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
|     } | ||||
| } | ||||
							
								
								
									
										234
									
								
								SodaLive/Sources/Message/Text/TextMessageViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								SodaLive/Sources/Message/Text/TextMessageViewModel.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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<AnyCancellable>() | ||||
|      | ||||
|     @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<GetTextMessageResponse>.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<GetTextMessageResponse>.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<GetTextMessageResponse>.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) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										153
									
								
								SodaLive/Sources/Message/Text/Write/TextMessageWriteView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								SodaLive/Sources/Message/Text/Write/TextMessageWriteView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
|     } | ||||
| } | ||||
							
								
								
									
										25
									
								
								SodaLive/Sources/Message/Voice/GetVoiceMessageResponse.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								SodaLive/Sources/Message/Voice/GetVoiceMessageResponse.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										164
									
								
								SodaLive/Sources/Message/Voice/SoundManager.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								SodaLive/Sources/Message/Voice/SoundManager.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
|     } | ||||
| } | ||||
							
								
								
									
										187
									
								
								SodaLive/Sources/Message/Voice/VoiceMessageItemView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								SodaLive/Sources/Message/Voice/VoiceMessageItemView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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: {} | ||||
|         ) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										215
									
								
								SodaLive/Sources/Message/Voice/VoiceMessageView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										215
									
								
								SodaLive/Sources/Message/Voice/VoiceMessageView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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..<viewModel.items.count, id: \.self) { index in | ||||
|                                     let item = viewModel.items[index] | ||||
|                                     VoiceMessageItemView( | ||||
|                                         index: index, | ||||
|                                         item: item, | ||||
|                                         currentFilter: viewModel.currentFilter, | ||||
|                                         soundManager: soundManager, | ||||
|                                         openPlayerItemIndex: $viewModel.openPlayerItemIndex, | ||||
|                                         onClickSave: { | ||||
|                                             viewModel.selectedMessageId = item.messageId | ||||
|                                             soundManager.stopAudio() | ||||
|                                             if item.isKept { | ||||
|                                                 viewModel.errorMessage = "이미 보관된 메시지 입니다" | ||||
|                                                 viewModel.isShowPopup = true | ||||
|                                                 return | ||||
|                                             } else { | ||||
|                                             } | ||||
|                                         }, | ||||
|                                         onClickReply: { | ||||
|                                             viewModel.selectedMessageId = item.messageId | ||||
|                                             soundManager.stopAudio() | ||||
|                                         }, | ||||
|                                         onClickDelete: { | ||||
|                                             viewModel.selectedMessageId = item.messageId | ||||
|                                             soundManager.stopAudio() | ||||
|                                             viewModel.deleteMessage() | ||||
|                                         } | ||||
|                                     ) | ||||
|                                     .onAppear { | ||||
|                                         if index == viewModel.items.count - 1 { | ||||
|                                             viewModel.loadMessage() | ||||
|                                         } | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                         } 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_voice") | ||||
|                     .resizable() | ||||
|                     .padding(13.3) | ||||
|                     .frame(width: 53.3, height: 53.3) | ||||
|                     .background(Color(hex: "9970ff")) | ||||
|                     .cornerRadius(26.7) | ||||
|                     .padding(.bottom, 33.3) | ||||
|                     .onTapGesture { | ||||
|                         AppState.shared.setAppStep( | ||||
|                             step: .writeVoiceMessage( | ||||
|                                 userId: nil, | ||||
|                                 nickname: nil, | ||||
|                                 onRefresh: { | ||||
|                                     viewModel.refresh() | ||||
|                                 } | ||||
|                             ) | ||||
|                         ) | ||||
|                     } | ||||
|             } | ||||
|              | ||||
|             if viewModel.isShowSavePopup { | ||||
|                 ZStack { | ||||
|                     Color.black.opacity(0.7) | ||||
|                         .ignoresSafeArea() | ||||
|                      | ||||
|                     VStack(spacing: 0) { | ||||
|                         Text("메시지 보관") | ||||
|                             .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                             .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                             .padding(.top, 40) | ||||
|                          | ||||
|                         Text("메시지를 보관하는데\n\(viewModel.saveMessagePrice)캔이 필요합니다.\n메시지를 보관하시겠습니까?") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 15)) | ||||
|                             .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                             .padding(.top, 13.3) | ||||
|                             .multilineTextAlignment(.center) | ||||
|                          | ||||
|                         Text("※ 메시지 보관시, 본인이 삭제하기 전까지 영구보관됩니다.") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                             .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                             .padding(.top, 13.3) | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                             .multilineTextAlignment(.center) | ||||
|                          | ||||
|                         HStack(spacing: 13.3) { | ||||
|                             Text("취소") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                                 .foregroundColor(Color(hex: "9970ff")) | ||||
|                                 .padding(.vertical, 16) | ||||
|                                 .frame(width: (screenSize().width - 66.7) / 3) | ||||
|                                 .background(Color(hex: "9970ff").opacity(0.2)) | ||||
|                                 .cornerRadius(10) | ||||
|                                 .overlay( | ||||
|                                     RoundedRectangle(cornerRadius: 10) | ||||
|                                         .stroke( | ||||
|                                             Color(hex: "9970ff"), | ||||
|                                             lineWidth: 1 | ||||
|                                         ) | ||||
|                                 ) | ||||
|                                 .onTapGesture { | ||||
|                                     viewModel.isShowSavePopup = false | ||||
|                                 } | ||||
|                              | ||||
|                             Text("확인") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                                 .foregroundColor(.white) | ||||
|                                 .padding(.vertical, 16) | ||||
|                                 .frame(width: (screenSize().width - 66.7) * 2 / 3) | ||||
|                                 .background(Color(hex: "9970ff")) | ||||
|                                 .cornerRadius(10) | ||||
|                                 .onTapGesture { | ||||
|                                     viewModel.isShowSavePopup = false | ||||
|                                     viewModel.keepTextMessage() | ||||
|                                 } | ||||
|                         } | ||||
|                         .padding(.vertical, 20) | ||||
|                         .padding(.horizontal, 16.7) | ||||
|                     } | ||||
|                     .frame(width: screenSize().width - 26.7) | ||||
|                     .background(Color(hex: "222222")) | ||||
|                     .cornerRadius(10) | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             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) | ||||
|                     Spacer() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         .popup(isPresented: $soundManager.isShowPopup, type: .toast, position: .top, autohideIn: 2) { | ||||
|             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) | ||||
|                     Spacer() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         .onAppear { | ||||
|             viewModel.refresh() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct VoiceMessageView_Previews: PreviewProvider { | ||||
|     static var previews: some View { | ||||
|         VoiceMessageView() | ||||
|     } | ||||
| } | ||||
							
								
								
									
										380
									
								
								SodaLive/Sources/Message/Voice/VoiceMessageViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										380
									
								
								SodaLive/Sources/Message/Voice/VoiceMessageViewModel.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,380 @@ | ||||
| // | ||||
| //  VoiceMessageViewModel.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/10. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| import Moya | ||||
| import Combine | ||||
|  | ||||
| final class VoiceMessageViewModel: ObservableObject { | ||||
|     private let repository = MessageRepository() | ||||
|     private var subscription = Set<AnyCancellable>() | ||||
|      | ||||
|     @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<GetVoiceMessageResponse>.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<GetVoiceMessageResponse>.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<GetVoiceMessageResponse>.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) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										337
									
								
								SodaLive/Sources/Message/Voice/Write/VoiceMessageWriteView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										337
									
								
								SodaLive/Sources/Message/Voice/Write/VoiceMessageWriteView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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: {}) | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|   | ||||
| @@ -24,4 +24,8 @@ final class UserRepository { | ||||
|     func findPassword(email: String) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.findPassword(request: ForgotPasswordRequest(email: email))) | ||||
|     } | ||||
|      | ||||
|     func searchUser(nickname: String) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.searchUser(nickname: nickname)) | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Yu Sung
					Yu Sung