메시지 - 리스트, 쓰기, 상세 페이지 추가

This commit is contained in:
Yu Sung 2023-08-10 15:21:08 +09:00
parent 943e1d9f7f
commit 80ff04f825
36 changed files with 2969 additions and 2 deletions

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

View File

@ -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)
}

View 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"
)
}
}

View File

@ -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)

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,12 @@
//
// KeepMessageRequest.swift
// SodaLive
//
// Created by klaus on 2023/08/10.
//
import Foundation
struct KeepMessageRequest: Encodable {
let container = "ios"
}

View 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))"]
}
}

View File

@ -0,0 +1,12 @@
//
// MessageFilterTab.swift
// SodaLive
//
// Created by klaus on 2023/08/10.
//
import Foundation
enum MessageFilterTab {
case receive, sent, keep
}

View 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))
}
}

View 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))
}
}

View File

@ -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"))
}
}
}

View 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
}

View File

@ -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 }
}
}

View File

@ -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)
}
}
}

View 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"
}

View 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: {}
)
}
}

View File

@ -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)
}
}

View 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
}

View 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
)
)
}
}

View 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()
}
}

View 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)
}
}

View 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()
}
}

View 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
}

View 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()
}
}

View 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: {}
)
}
}

View 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()
}
}

View 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)
}
}

View 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: {})
}
}

View File

@ -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)
}
}

View File

@ -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))
}
}