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

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