콘텐츠 추가
							
								
								
									
										2
									
								
								Podfile
									
									
									
									
									
								
							
							
						
						| @@ -7,6 +7,7 @@ target 'SodaLive' do | ||||
|  | ||||
|   # Pods for SodaLive | ||||
|   pod 'BootpayUI', '4.3.0' | ||||
|   pod 'ObjectBox' | ||||
|  | ||||
| end | ||||
|  | ||||
| @@ -16,6 +17,7 @@ target 'SodaLive-dev' do | ||||
|  | ||||
|   # Pods for SodaLive-dev | ||||
|   pod 'BootpayUI', '4.3.0' | ||||
|   pod 'ObjectBox' | ||||
|  | ||||
| end | ||||
|  | ||||
|   | ||||
| @@ -14,6 +14,7 @@ PODS: | ||||
|     - SwiftyJSON | ||||
|   - CryptoSwift (1.7.1) | ||||
|   - JGProgressHUD (2.2) | ||||
|   - ObjectBox (1.8.1) | ||||
|   - ObjectMapper (4.2.0) | ||||
|   - SCLAlertView (0.8) | ||||
|   - SnapKit (5.6.0) | ||||
| @@ -21,6 +22,7 @@ PODS: | ||||
|  | ||||
| DEPENDENCIES: | ||||
|   - BootpayUI (= 4.3.0) | ||||
|   - ObjectBox | ||||
|  | ||||
| SPEC REPOS: | ||||
|   trunk: | ||||
| @@ -29,6 +31,7 @@ SPEC REPOS: | ||||
|     - BootpayUI | ||||
|     - CryptoSwift | ||||
|     - JGProgressHUD | ||||
|     - ObjectBox | ||||
|     - ObjectMapper | ||||
|     - SCLAlertView | ||||
|     - SnapKit | ||||
| @@ -40,11 +43,12 @@ SPEC CHECKSUMS: | ||||
|   BootpayUI: 54dcbe59a23e0d91b07a8add8115e1a6deace0f0 | ||||
|   CryptoSwift: d3d18dc357932f7e6d580689e065cf1f176007c1 | ||||
|   JGProgressHUD: d83d7a981b85d11205e19ff8ad5bb9c40571c847 | ||||
|   ObjectBox: a7900d5335218cd437cbc080b7ccc38a5211f7b4 | ||||
|   ObjectMapper: 1eb41f610210777375fa806bf161dc39fb832b81 | ||||
|   SCLAlertView: 6a77bb2edfc65e04dbe57725546cb4107a506b85 | ||||
|   SnapKit: e01d52ebb8ddbc333eefe2132acf85c8227d9c25 | ||||
|   SwiftyJSON: 2f33a42c6fbc52764d96f13368585094bfd8aa5e | ||||
|  | ||||
| PODFILE CHECKSUM: 2581dac8090335f039e33fdbf3ec7d78d7f961e8 | ||||
| PODFILE CHECKSUM: cdff30c96e85662f4de75ddd8d54358311c1e629 | ||||
|  | ||||
| COCOAPODS: 1.12.1 | ||||
|   | ||||
| @@ -170,6 +170,15 @@ | ||||
|         "revision" : "ce20dc083ee485524b802669890291c0d8090170", | ||||
|         "version" : "1.22.1" | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       "identity" : "swiftui-sliders", | ||||
|       "kind" : "remoteSourceControl", | ||||
|       "location" : "https://github.com/spacenation/swiftui-sliders.git", | ||||
|       "state" : { | ||||
|         "revision" : "d5a7d856655d5c91f891c2b69d982c30fd5c7bdf", | ||||
|         "version" : "2.1.0" | ||||
|       } | ||||
|     } | ||||
|   ], | ||||
|   "version" : 2 | ||||
|   | ||||
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/btn_audio_content_pause.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "btn_audio_content_pause.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/btn_audio_content_pause.imageset/btn_audio_content_pause.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.1 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/btn_audio_content_play.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "btn_audio_content_play.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/btn_audio_content_play.imageset/btn_audio_content_play.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.8 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/btn_player_repeat.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "btn_player_repeat.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/btn_player_repeat.imageset/btn_player_repeat.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 973 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/btn_player_repeat_done.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "btn_player_repeat_done.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/btn_player_repeat_done.imageset/btn_player_repeat_done.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.0 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_normal.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_audio_content_heart_normal.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
| After Width: | Height: | Size: 758 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_pressed.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_audio_content_heart_pressed.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
| After Width: | Height: | Size: 606 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_audio_content_share.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_audio_content_share.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_audio_content_share.imageset/ic_audio_content_share.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 708 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_circle_x_white.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_circle_x_white.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_circle_x_white.imageset/ic_circle_x_white.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.2 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_donation_white.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_donation_white.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_donation_white.imageset/ic_donation_white.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 891 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_notice_exclamation_mark.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_notice_exclamation_mark.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
| After Width: | Height: | Size: 2.9 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_review.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_review.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_review.imageset/ic_review.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 478 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_time_l.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_time_l.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_time_l.imageset/ic_time_l.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 910 B | 
| @@ -56,6 +56,10 @@ enum AppStep { | ||||
|      | ||||
|     case createContent | ||||
|      | ||||
|     case modifyContent(contentId: Int) | ||||
|      | ||||
|     case contentDetail(contentId: Int) | ||||
|      | ||||
|     case liveReservationComplete(response: MakeLiveReservationResponse) | ||||
|      | ||||
|     case creatorDetail(userId: Int) | ||||
|   | ||||
							
								
								
									
										34
									
								
								SodaLive/Sources/App/ObjectBoxService.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,34 @@ | ||||
| // | ||||
| //  ObjectBoxService.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/11. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import ObjectBox | ||||
|  | ||||
| class ObjectBoxService { | ||||
|     let store: Store! | ||||
|     let playbackTrackingBox: Box<PlaybackTracking> | ||||
|      | ||||
|     init() { | ||||
|         let databaseName = "yozmlive" | ||||
|         let appSupport = try! FileManager.default.url(for: .applicationSupportDirectory, | ||||
|                                                       in: .userDomainMask, | ||||
|                                                       appropriateFor: nil, | ||||
|                                                       create: true) | ||||
|             .appendingPathComponent(Bundle.main.bundleIdentifier!) | ||||
|         let directory = appSupport.appendingPathComponent(databaseName) | ||||
|         try? FileManager.default.createDirectory(at: directory, | ||||
|                                                  withIntermediateDirectories: true, | ||||
|                                                  attributes: nil) | ||||
|          | ||||
|         if try! Store.isOpen(directory: directory.path) { | ||||
|             self.store = try! Store.attachTo(directory: directory.path) | ||||
|         } else { | ||||
|             self.store = try! Store(directoryPath: directory.path) | ||||
|         } | ||||
|         self.playbackTrackingBox = store.box(for: PlaybackTracking.self) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										282
									
								
								SodaLive/Sources/Content/ContentPlayManager.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,282 @@ | ||||
| // | ||||
| //  ContentPlayManager.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/11. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import AVKit | ||||
| import MediaPlayer | ||||
| import ObjectBox | ||||
|  | ||||
| final class ContentPlayManager: NSObject, ObservableObject { | ||||
|     static let shared = ContentPlayManager() | ||||
|      | ||||
|     var creatorId = 0 | ||||
|     @Published var contentId: Int = 0 | ||||
|      | ||||
|     @Published private (set) var duration: TimeInterval = 0 | ||||
|      | ||||
|     @Published var title = "" | ||||
|     @Published var nickname = "" | ||||
|     @Published var coverImage = "" | ||||
|      | ||||
|     @Published var isFree: Bool? = nil | ||||
|     @Published var isPreview: Bool? = nil | ||||
|     @Published private (set) var isShowingMiniPlayer = false | ||||
|     @Published private (set) var isPlaying = false | ||||
|      | ||||
|     @Published var isLoading = false | ||||
|     @Published var errorMessage = "" | ||||
|     @Published var isShowPopup = false | ||||
|      | ||||
|     var player: AVAudioPlayer! | ||||
|      | ||||
|     var startTimer: (() -> Void)? | ||||
|     var stopTimer: (() -> Void)? | ||||
|      | ||||
|     private var playbackTrackingId: Id = 0 | ||||
|     private let repository = PlaybackTrackingRepository() | ||||
| } | ||||
|  | ||||
| extension ContentPlayManager { | ||||
|     func playAudio( | ||||
|         creatorId: Int = 0, | ||||
|         contentId: Int = 0, | ||||
|         title: String = "", | ||||
|         nickname: String = "", | ||||
|         coverImage: String = "", | ||||
|         contentUrl: String = "", | ||||
|         isFree: Bool? = nil, | ||||
|         isPreview: Bool? = nil | ||||
|     ) { | ||||
|         if contentId <= 0 { | ||||
|             return | ||||
|         } | ||||
|          | ||||
|         if let startTimer = startTimer { | ||||
|             startTimer() | ||||
|         } | ||||
|          | ||||
|         if self.contentId > 0 && self.contentId == contentId { | ||||
|             player?.play() | ||||
|             isPlaying = player.isPlaying | ||||
|         } else { | ||||
|             isLoading = true | ||||
|             stopAudio() | ||||
|              | ||||
|             self.creatorId = creatorId | ||||
|             self.contentId = contentId | ||||
|             self.title = title | ||||
|             self.nickname = nickname | ||||
|             self.coverImage = coverImage | ||||
|             self.isFree = isFree | ||||
|             self.isPreview = isPreview | ||||
|              | ||||
|             guard let url = URL(string: contentUrl) else { | ||||
|                 showError() | ||||
|                 return | ||||
|             } | ||||
|              | ||||
|             URLSession.shared.dataTask(with: url) { [unowned self] data, response, error in | ||||
|                 guard let audioData = data else { | ||||
|                     self.isLoading = false | ||||
|                     return | ||||
|                 } | ||||
|                  | ||||
|                 do { | ||||
|                     let audioSession = AVAudioSession.sharedInstance() | ||||
|                     try audioSession.setCategory(.playback, mode: .default) | ||||
|                     try audioSession.setActive(true) | ||||
|                      | ||||
|                     self.player = try AVAudioPlayer(data: audioData) | ||||
|                     saveNewPlaybackTracking(totalDuration: Int(player.duration), progress: 0) | ||||
|                      | ||||
|                     DispatchQueue.main.async { | ||||
|                         self.player?.volume = 1 | ||||
|                         self.player?.delegate = self | ||||
|                         self.player?.prepareToPlay() | ||||
|                          | ||||
|                         self.duration = self.player.duration | ||||
|                         self.player?.play() | ||||
|                         self.isPlaying = self.player.isPlaying | ||||
|                         self.isShowingMiniPlayer = true | ||||
|                         UIApplication.shared.beginReceivingRemoteControlEvents() | ||||
|                     } | ||||
|                      | ||||
|                     self.registerNowPlayingInfoCenter() | ||||
|                     self.registerRemoteControlEvents() | ||||
|                 } catch { | ||||
|                     DispatchQueue.main.async { | ||||
|                         self.showError() | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 DispatchQueue.main.async { | ||||
|                     self.isLoading = false | ||||
|                 } | ||||
|             }.resume() | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func stopAudio() { | ||||
|         if let player = player { | ||||
|             player.stop() | ||||
|             setEndPositionPlaybackTracking(progress: Int(player.currentTime)) | ||||
|              | ||||
|             player.currentTime = 0 | ||||
|             isPlaying = player.isPlaying | ||||
|         } | ||||
|          | ||||
|         resetAudioData() | ||||
|         unRegisterRemoteControlEvents() | ||||
|     } | ||||
|      | ||||
|     func conditionalStopAudio(contentId: Int) { | ||||
|         if self.contentId == contentId { | ||||
|             stopAudio() | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func pauseAudio() { | ||||
|         if let player = player { | ||||
|             player.pause() | ||||
|             isPlaying = player.isPlaying | ||||
|             if let stopTimer = stopTimer { | ||||
|                 stopTimer() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func resetAudioData() { | ||||
|         title = "" | ||||
|         nickname = "" | ||||
|         coverImage = "" | ||||
|         contentId = 0 | ||||
|         duration = 0 | ||||
|          | ||||
|         isPreview = false | ||||
|         isShowingMiniPlayer = false | ||||
|         player = nil | ||||
|         startTimer = nil | ||||
|         stopTimer = nil | ||||
|     } | ||||
|      | ||||
|     func setCurrentTime(_ progress: TimeInterval) { | ||||
|         if let player = player, contentId > 0 { | ||||
|             player.currentTime = progress | ||||
|             saveNewPlaybackTracking(totalDuration: Int(player.duration), progress: Int(progress)) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func repeatAudio() { | ||||
|         if let stopTimer = stopTimer { | ||||
|             stopTimer() | ||||
|         } | ||||
|          | ||||
|         player.stop() | ||||
|         setEndPositionPlaybackTracking(progress: Int(player.currentTime)) | ||||
|         player.currentTime = 0 | ||||
|          | ||||
|         saveNewPlaybackTracking(totalDuration: Int(player.duration), progress: 0) | ||||
|         player.play() | ||||
|          | ||||
|         if let startTimer = startTimer { | ||||
|             startTimer() | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func showError() { | ||||
|         self.errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." | ||||
|         self.isShowPopup = true | ||||
|         self.resetAudioData() | ||||
|     } | ||||
|      | ||||
|     private func registerNowPlayingInfoCenter() { | ||||
|         let center = MPNowPlayingInfoCenter.default() | ||||
|         var nowPlayingInfo = center.nowPlayingInfo ?? [String: Any]() | ||||
|          | ||||
|         nowPlayingInfo[MPMediaItemPropertyTitle] = title | ||||
|         nowPlayingInfo[MPMediaItemPropertyArtist] = nickname | ||||
|         if let artworkURL = URL(string: coverImage), let imageData = try? Data(contentsOf: artworkURL), let artworkImage = UIImage(data: imageData) { | ||||
|             let artwork = MPMediaItemArtwork(boundsSize: artworkImage.size) { size in | ||||
|                 return artworkImage | ||||
|             } | ||||
|             nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork | ||||
|         } | ||||
|          | ||||
|         // 콘텐츠 총 길이 | ||||
|         nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.duration | ||||
|         // 콘텐츠 재생 시간에 따른 progressBar 초기화 | ||||
|         nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate | ||||
|         // 콘텐츠 현재 재생시간 | ||||
|         nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime | ||||
|          | ||||
|         center.nowPlayingInfo = nowPlayingInfo | ||||
|     } | ||||
|      | ||||
|     private func registerRemoteControlEvents() { | ||||
|         let center = MPRemoteCommandCenter.shared() | ||||
|          | ||||
|         center.playCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in | ||||
|             if let player = player { | ||||
|                 player.play() | ||||
|                 self.isPlaying = player.isPlaying | ||||
|                 if let startTimer = self.startTimer { | ||||
|                     startTimer() | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             return .success | ||||
|         } | ||||
|          | ||||
|         center.pauseCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in | ||||
|             self.pauseAudio() | ||||
|             return .success | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func unRegisterRemoteControlEvents() { | ||||
|         let center = MPRemoteCommandCenter.shared() | ||||
|         center.playCommand.removeTarget(nil) | ||||
|         center.pauseCommand.removeTarget(nil) | ||||
|         UIApplication.shared.endReceivingRemoteControlEvents() | ||||
|     } | ||||
| } | ||||
|  | ||||
| extension ContentPlayManager { | ||||
|     private func saveNewPlaybackTracking(totalDuration: Int, progress: Int) { | ||||
|         if creatorId != UserDefaults.int(forKey: .userId) { | ||||
|             playbackTrackingId = repository | ||||
|                 .savePlaybackTracking(data: PlaybackTracking( | ||||
|                     audioContentId: contentId, | ||||
|                     totalDuration: totalDuration, | ||||
|                     startPosition: progress, | ||||
|                     isFree: isFree ?? true, | ||||
|                     isPreview: isPreview ?? true) | ||||
|                 ) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func setEndPositionPlaybackTracking(progress: Int) { | ||||
|         if creatorId != UserDefaults.int(forKey: .userId) && playbackTrackingId > 0 { | ||||
|             if let playbackTracking = repository.getPlaybackTracking(id: playbackTrackingId) { | ||||
|                 playbackTracking.endPosition = progress | ||||
|                 _ = repository.savePlaybackTracking(data: playbackTracking) | ||||
|             } | ||||
|              | ||||
|             playbackTrackingId = 0 | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| extension ContentPlayManager: AVAudioPlayerDelegate { | ||||
|     func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { | ||||
|         if UserDefaults.bool(forKey: .isContentPlayLoop) { | ||||
|             repeatAudio() | ||||
|         } else { | ||||
|             stopAudio() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -22,11 +22,11 @@ final class ContentRepository { | ||||
|     } | ||||
|      | ||||
|     func likeContent(audioContentId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.likeContent(request: PutAudioContentLikeRequest(audioContentId: audioContentId))) | ||||
|         return api.requestPublisher(.likeContent(request: PutAudioContentLikeRequest(contentId: audioContentId))) | ||||
|     } | ||||
|      | ||||
|     func registerComment(audioContentId: Int, comment: String, parentId: Int? = nil) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.registerComment(request: RegisterAudioContentCommentRequest(comment: comment, audioContentId: audioContentId, parentId: parentId))) | ||||
|         return api.requestPublisher(.registerComment(request: RegisterAudioContentCommentRequest(comment: comment, contentId: audioContentId, parentId: parentId))) | ||||
|     } | ||||
|      | ||||
|     func orderAudioContent(audioContentId: Int, orderType: OrderType) -> AnyPublisher<Response, MoyaError> { | ||||
| @@ -73,7 +73,7 @@ final class ContentRepository { | ||||
|         return api.requestPublisher(.getNewContentOfTheme(theme: theme)) | ||||
|     } | ||||
|      | ||||
|     func donation(contentId: Int, coin: Int, comment: String) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.donation(request: AudioContentDonationRequest(audioContentId: contentId, donationCoin: coin, comment: comment))) | ||||
|     func donation(contentId: Int, can: Int, comment: String) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.donation(request: AudioContentDonationRequest(audioContentId: contentId, donationCan: can, comment: comment))) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,97 @@ | ||||
| // | ||||
| //  AudioContentDeleteDialogView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct AudioContentDeleteDialogView: View { | ||||
|      | ||||
|     @Binding var isShowing: Bool | ||||
|      | ||||
|     let title: String | ||||
|     let confirmAction: () -> Void | ||||
|     let showToast: () -> Void | ||||
|      | ||||
|     @State private var isAgree = false | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(spacing: 0) { | ||||
|             Text("콘텐츠 삭제") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|              | ||||
|             Text("[\(title)]을 삭제하시겠습니까?") | ||||
|                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .padding(.top, 21.3) | ||||
|              | ||||
|             HStack(spacing: 13.3) { | ||||
|                 Image(isAgree ? "btn_select_checked" : "btn_select_normal") | ||||
|                     .resizable() | ||||
|                     .frame(width: 20, height: 20) | ||||
|                     .onTapGesture { | ||||
|                         isAgree.toggle() | ||||
|                     } | ||||
|                  | ||||
|                 Text("삭제된 콘텐츠는 되돌릴 수 없음을 알고 있습니다.") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                     .onTapGesture { | ||||
|                         isAgree.toggle() | ||||
|                     } | ||||
|             } | ||||
|             .padding(13.3) | ||||
|             .background(Color(hex: "303030")) | ||||
|             .cornerRadius(6.7) | ||||
|             .padding(.top, 13.3) | ||||
|              | ||||
|             Text("콘텐츠를 삭제하더라도 이미 구매한\n사용자는 콘텐츠를 이용할 수 있습니다.") | ||||
|                 .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                 .foregroundColor(Color(hex: "dd4500")) | ||||
|                 .fixedSize(horizontal: false, vertical: true) | ||||
|                 .multilineTextAlignment(.center) | ||||
|                 .padding(.top, 13.3) | ||||
|              | ||||
|             HStack(spacing: 12) { | ||||
|                 Text("취소") | ||||
|                     .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                     .foregroundColor(Color(hex: "9970ff")) | ||||
|                     .padding(.horizontal, 55) | ||||
|                     .padding(.vertical, 16) | ||||
|                     .overlay( | ||||
|                         RoundedRectangle(cornerRadius: CGFloat(10)) | ||||
|                             .stroke(lineWidth: 1) | ||||
|                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                     ) | ||||
|                     .onTapGesture { | ||||
|                         isShowing = false | ||||
|                     } | ||||
|                  | ||||
|                 Text("확인") | ||||
|                     .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                     .padding(.horizontal, 55) | ||||
|                     .padding(.vertical, 16) | ||||
|                     .background(Color(hex: "9970ff")) | ||||
|                     .cornerRadius(10) | ||||
|                     .onTapGesture { | ||||
|                         if isAgree { | ||||
|                             isShowing = false | ||||
|                             confirmAction() | ||||
|                         } else { | ||||
|                             showToast() | ||||
|                         } | ||||
|                     } | ||||
|             } | ||||
|             .padding(.top, 13.3) | ||||
|         } | ||||
|         .padding(.top, 40) | ||||
|         .padding(.horizontal, 16.7) | ||||
|         .padding(.bottom, 16.7) | ||||
|         .background(Color(hex: "222222")) | ||||
|         .cornerRadius(10) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,104 @@ | ||||
| // | ||||
| //  AudioContentReportDialogView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct AudioContentReportDialogView: View { | ||||
|      | ||||
|     @Binding var isShowing: Bool | ||||
|     let confirmAction: (String) -> Void | ||||
|      | ||||
|     @State private var selectedIndex: Int? = nil | ||||
|     let reasons = [ | ||||
|         "괴롭힘 및 사이버 폭력", | ||||
|         "개인정보 침해", | ||||
|         "명의도용", | ||||
|         "폭력적 위협", | ||||
|         "아동학대", | ||||
|         "보호대상 집단에 대한 증오심 표현", | ||||
|         "스팸 및 사기" | ||||
|     ] | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             Color.black | ||||
|                 .opacity(0.7) | ||||
|                 .ignoresSafeArea() | ||||
|                 .onTapGesture { isShowing = false } | ||||
|              | ||||
|             VStack(spacing: 13.3) { | ||||
|                 Text("콘텐츠 신고") | ||||
|                     .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                  | ||||
|                 VStack(spacing: 13.3) { | ||||
|                     ForEach(0..<reasons.count, id: \.self) { index in | ||||
|                         let reason = reasons[index] | ||||
|                         HStack(spacing: 8) { | ||||
|                             Image(selectedIndex == index ? "btn_radio_select_selected" : "btn_radio_select_normal") | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 20, height: 20) | ||||
|                              | ||||
|                             Text(reason) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 14)) | ||||
|                                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                              | ||||
|                             Spacer() | ||||
|                         } | ||||
|                         .onTapGesture { | ||||
|                             selectedIndex = index | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 .padding(13.3) | ||||
|                 .background(Color(hex: "303030")) | ||||
|                 .cornerRadius(6.7) | ||||
|                 .padding(.vertical, 21.3) | ||||
|                  | ||||
|                 Text("신고한 콘텐츠를 관리자가 확인 후, 서비스정책을\n위반한 경우 삭제 조치할 예정입니다.") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color(hex: "dd4500")) | ||||
|                     .multilineTextAlignment(.center) | ||||
|                  | ||||
|                 HStack(spacing: 12) { | ||||
|                     Text("취소") | ||||
|                         .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                         .foregroundColor(Color(hex: "9970ff")) | ||||
|                         .padding(.vertical, 16) | ||||
|                         .frame(width: (screenSize().width - 100) / 2) | ||||
|                         .overlay( | ||||
|                             RoundedRectangle(cornerRadius: CGFloat(10)) | ||||
|                                 .stroke(lineWidth: 1) | ||||
|                                 .foregroundColor(Color(hex: "9970ff")) | ||||
|                         ) | ||||
|                         .onTapGesture { | ||||
|                             isShowing = false | ||||
|                         } | ||||
|                      | ||||
|                     Text("신고") | ||||
|                         .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                         .padding(.vertical, 16) | ||||
|                         .frame(width: (screenSize().width - 100) / 2) | ||||
|                         .background(Color(hex: "9970ff")) | ||||
|                         .cornerRadius(10) | ||||
|                         .onTapGesture { | ||||
|                             if let selectedIndex = selectedIndex { | ||||
|                                 isShowing = false | ||||
|                                 confirmAction(reasons[selectedIndex]) | ||||
|                             } | ||||
|                         } | ||||
|                 } | ||||
|                 .padding(.top, 13.3) | ||||
|             } | ||||
|             .padding(24) | ||||
|             .frame(width: screenSize().width - 33.3) | ||||
|             .background(Color(hex: "222222")) | ||||
|             .cornerRadius(13.3) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,89 @@ | ||||
| // | ||||
| //  AudioContentCommentItemView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct AudioContentCommentItemView: View { | ||||
|      | ||||
|     let comment: GetAudioContentCommentListItem | ||||
|     let isReplyComment: Bool | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading, spacing: 0) { | ||||
|             HStack(spacing: 6.7) { | ||||
|                 KFImage(URL(string: comment.profileUrl)) | ||||
|                     .resizable() | ||||
|                     .frame(width: 40, height: 40) | ||||
|                     .clipShape(Circle()) | ||||
|                  | ||||
|                 VStack(alignment: .leading, spacing: 0) { | ||||
|                     Text(comment.nickname) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                      | ||||
|                     Text(comment.date) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 10.3)) | ||||
|                         .foregroundColor(Color(hex: "525252")) | ||||
|                         .padding(.top, 4) | ||||
|                 } | ||||
|                  | ||||
|                 Spacer() | ||||
|             } | ||||
|              | ||||
|             if comment.donationCan > 0 { | ||||
|                 HStack(spacing: 3) { | ||||
|                     Image("ic_can") | ||||
|                         .resizable() | ||||
|                         .frame(width: 13.3, height: 13.3) | ||||
|                      | ||||
|                     Text("\(comment.donationCan)") | ||||
|                         .font(.custom(Font.bold.rawValue, size: 12)) | ||||
|                         .foregroundColor(.white) | ||||
|                 } | ||||
|                 .padding(.horizontal, 6.7) | ||||
|                 .padding(.vertical, 2.7) | ||||
|                 .background( | ||||
|                     comment.donationCan >= 100000 ? Color(hex: "973a3a") : | ||||
|                         comment.donationCan >= 50000 ? Color(hex: "d85e37") : | ||||
|                         comment.donationCan >= 10000 ? Color(hex: "d38c38") : | ||||
|                         comment.donationCan >= 5000 ? Color(hex: "59548f") : | ||||
|                         comment.donationCan >= 1000 ? Color(hex: "4d6aa4") : | ||||
|                         comment.donationCan >= 500 ? Color(hex: "2d7390") : | ||||
|                         Color(hex: "548f7d") | ||||
|                 ) | ||||
|                 .cornerRadius(10.7) | ||||
|                 .padding(.leading, 46.7) | ||||
|                 .padding(.bottom, 5) | ||||
|             } | ||||
|              | ||||
|             HStack(spacing: 0) { | ||||
|                 VStack(alignment: .leading, spacing: 13.3) { | ||||
|                     Text(comment.comment) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                         .foregroundColor(Color(hex: "777777")) | ||||
|                         .fixedSize(horizontal: false, vertical: true) | ||||
|                         .padding(.top, comment.donationCan > 0 ? 0 : 13.3) | ||||
|                      | ||||
|                     if !isReplyComment { | ||||
|                         Text(comment.replyCount > 0 ? "답글 \(comment.replyCount)개" : "답글 쓰기") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 Spacer() | ||||
|             } | ||||
|             .padding(.leading, 46.7) | ||||
|              | ||||
|             Rectangle() | ||||
|                 .foregroundColor(Color(hex: "595959")) | ||||
|                 .frame(height: 0.5) | ||||
|                 .padding(.top, 16.7) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,146 @@ | ||||
| // | ||||
| //  AudioContentCommentListView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct AudioContentCommentListView: View { | ||||
|      | ||||
|     @Binding var isPresented: Bool | ||||
|     let audioContentId: Int | ||||
|      | ||||
|     @StateObject var viewModel = AudioContentCommentListViewModel() | ||||
|      | ||||
|     var body: some View { | ||||
|         NavigationView { | ||||
|             ZStack { | ||||
|                 VStack(spacing: 0) { | ||||
|                     HStack(spacing: 0) { | ||||
|                         Text("댓글") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(.white) | ||||
|                             .padding(.leading, 13.3) | ||||
|                          | ||||
|                         Text("\(viewModel.totalCommentCount)") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                             .foregroundColor(Color(hex: "909090")) | ||||
|                             .padding(.leading, 6.7) | ||||
|                          | ||||
|                         Spacer() | ||||
|                          | ||||
|                         Image("ic_close_white") | ||||
|                             .onTapGesture { isPresented = false} | ||||
|                     } | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                     .padding(.top, 12) | ||||
|                      | ||||
|                     Rectangle() | ||||
|                         .foregroundColor(Color(hex: "595959")) | ||||
|                         .frame(height: 0.5) | ||||
|                         .padding(.top, 12) | ||||
|                         .padding(.bottom, 13.3) | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                      | ||||
|                     HStack(spacing: 8) { | ||||
|                         KFImage(URL(string: UserDefaults.string(forKey: .profileImage))) | ||||
|                         .cancelOnDisappear(true) | ||||
|                         .downsampling(size: CGSize(width: 33.3, height: 33.3)) | ||||
|                         .resizable() | ||||
|                         .frame(width: 33.3, height: 33.3) | ||||
|                         .clipShape(Circle()) | ||||
|                          | ||||
|                         HStack(spacing: 0) { | ||||
|                             TextField("댓글을 입력해 보세요.", text: $viewModel.comment) | ||||
|                                 .autocapitalization(.none) | ||||
|                                 .disableAutocorrection(true) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                                 .accentColor(Color(hex: "9970ff")) | ||||
|                                 .keyboardType(.default) | ||||
|                                 .padding(.horizontal, 13.3) | ||||
|                              | ||||
|                             Spacer() | ||||
|                              | ||||
|                             Image("btn_message_send") | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 35, height: 35) | ||||
|                                 .padding(6.7) | ||||
|                                 .onTapGesture { | ||||
|                                     hideKeyboard() | ||||
|                                     viewModel.registerComment() | ||||
|                                 } | ||||
|                         } | ||||
|                         .background(Color(hex: "232323")) | ||||
|                         .cornerRadius(10) | ||||
|                         .overlay( | ||||
|                             RoundedRectangle(cornerRadius: 10) | ||||
|                                 .strokeBorder(lineWidth: 1) | ||||
|                                 .foregroundColor(Color(hex: "9970ff")) | ||||
|                         ) | ||||
|                          | ||||
|                         Spacer() | ||||
|                     } | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                      | ||||
|                     Rectangle() | ||||
|                         .foregroundColor(Color(hex: "595959")) | ||||
|                         .frame(height: 0.5) | ||||
|                         .padding(.top, 12) | ||||
|                         .padding(.bottom, 13.3) | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                      | ||||
|                     ScrollView(.vertical, showsIndicators: false) { | ||||
|                         LazyVStack(spacing: 13.3) { | ||||
|                             ForEach(0..<viewModel.commentList.count, id: \.self) { index in | ||||
|                                 let comment = viewModel.commentList[index] | ||||
|                                 NavigationLink { | ||||
|                                     AudioContentListReplyView( | ||||
|                                         audioContentId: audioContentId, | ||||
|                                         parentComment: comment | ||||
|                                     ) | ||||
|                                 } label: { | ||||
|                                     AudioContentCommentItemView(comment: comment, isReplyComment: false) | ||||
|                                     .padding(.horizontal, 26.7) | ||||
|                                     .onAppear { | ||||
|                                         if index == viewModel.commentList.count - 1 { | ||||
|                                             viewModel.getCommentList() | ||||
|                                         } | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 if viewModel.isLoading { | ||||
|                     LoadingView() | ||||
|                 } | ||||
|             } | ||||
|             .onAppear { | ||||
|                 viewModel.audioContentId = audioContentId | ||||
|                 viewModel.getCommentList() | ||||
|             } | ||||
|             .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { | ||||
|                 GeometryReader { geo in | ||||
|                     HStack { | ||||
|                         Spacer() | ||||
|                         Text(viewModel.errorMessage) | ||||
|                             .padding(.vertical, 13.3) | ||||
|                             .frame(width: screenSize().width - 66.7, alignment: .center) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                             .background(Color(hex: "9970ff")) | ||||
|                             .foregroundColor(Color.white) | ||||
|                             .multilineTextAlignment(.center) | ||||
|                             .cornerRadius(20) | ||||
|                             .padding(.top, 66.7) | ||||
|                         Spacer() | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,124 @@ | ||||
| // | ||||
| //  AudioContentCommentListViewModel.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import Moya | ||||
| import Combine | ||||
|  | ||||
| class AudioContentCommentListViewModel: ObservableObject { | ||||
|      | ||||
|     private let repository = ContentRepository() | ||||
|     private var subscription = Set<AnyCancellable>() | ||||
|      | ||||
|     @Published var isLoading = false | ||||
|     @Published var errorMessage = "" | ||||
|     @Published var isShowPopup = false | ||||
|      | ||||
|     @Published var comment = "" | ||||
|     @Published var totalCommentCount = 0 | ||||
|     @Published var commentList = [GetAudioContentCommentListItem]() | ||||
|      | ||||
|     var audioContentId = 0 | ||||
|     var page = 1 | ||||
|     var isLast = false | ||||
|     private let pageSize = 10 | ||||
|      | ||||
|     func getCommentList() { | ||||
|         if (!isLast && !isLoading) { | ||||
|             repository | ||||
|                 .getAudioContentCommentList(audioContentId: audioContentId, page: page, size: pageSize) | ||||
|                 .sink { result in | ||||
|                     switch result { | ||||
|                     case .finished: | ||||
|                         DEBUG_LOG("finish") | ||||
|                     case .failure(let error): | ||||
|                         ERROR_LOG(error.localizedDescription) | ||||
|                     } | ||||
|                 } receiveValue: { [unowned self] response in | ||||
|                     let responseData = response.data | ||||
|                      | ||||
|                     do { | ||||
|                         let jsonDecoder = JSONDecoder() | ||||
|                         let decoded = try jsonDecoder.decode(ApiResponse<GetAudioContentCommentListResponse>.self, from: responseData) | ||||
|                          | ||||
|                         if let data = decoded.data, decoded.success { | ||||
|                             if page == 1 { | ||||
|                                 commentList.removeAll() | ||||
|                             } | ||||
|                              | ||||
|                             if !data.items.isEmpty { | ||||
|                                 page += 1 | ||||
|                                 self.totalCommentCount = data.totalCount | ||||
|                                 self.commentList.append(contentsOf: data.items) | ||||
|                             } else { | ||||
|                                 isLast = true | ||||
|                             } | ||||
|                         } else { | ||||
|                             if let message = decoded.message { | ||||
|                                 self.errorMessage = message | ||||
|                             } else { | ||||
|                                 self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                             } | ||||
|                              | ||||
|                             self.isShowPopup = true | ||||
|                         } | ||||
|                     } catch { | ||||
|                         self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                      | ||||
|                     self.isLoading = false | ||||
|                 } | ||||
|                 .store(in: &subscription) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func registerComment() { | ||||
|         if comment.trimmingCharacters(in: .whitespaces).isEmpty { | ||||
|             return | ||||
|         } | ||||
|          | ||||
|         isLoading = true | ||||
|          | ||||
|         repository.registerComment(audioContentId: audioContentId, comment: comment) | ||||
|             .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.comment = "" | ||||
|                         self.page = 1 | ||||
|                         self.isLast = false | ||||
|                         self.getCommentList() | ||||
|                     } 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) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,118 @@ | ||||
| // | ||||
| //  AudioContentListReplyView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct AudioContentListReplyView: View { | ||||
|      | ||||
|     let audioContentId: Int | ||||
|     let parentComment: GetAudioContentCommentListItem | ||||
|      | ||||
|     @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode> | ||||
|     @StateObject var viewModel = AudioContentListReplyViewModel() | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             VStack(spacing: 0) { | ||||
|                 HStack(spacing: 6.7) { | ||||
|                     Image("ic_back") | ||||
|                      | ||||
|                     Text("답글") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                         .foregroundColor(.white) | ||||
|                      | ||||
|                     Spacer() | ||||
|                 } | ||||
|                 .padding(.horizontal, 13.3) | ||||
|                 .padding(.top, 12) | ||||
|                 .onTapGesture { presentationMode.wrappedValue.dismiss() } | ||||
|                  | ||||
|                 Rectangle() | ||||
|                     .foregroundColor(Color(hex: "595959")) | ||||
|                     .frame(height: 0.5) | ||||
|                     .padding(.top, 12) | ||||
|                     .padding(.bottom, 13.3) | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                  | ||||
|                 HStack(spacing: 8) { | ||||
|                     KFImage(URL(string: UserDefaults.string(forKey: .profileImage))) | ||||
|                         .cancelOnDisappear(true) | ||||
|                         .downsampling(size: CGSize(width: 33.3, height: 33.3)) | ||||
|                         .resizable() | ||||
|                         .frame(width: 33.3, height: 33.3) | ||||
|                         .clipShape(Circle()) | ||||
|                      | ||||
|                     HStack(spacing: 0) { | ||||
|                         TextField("댓글을 입력해 보세요.", text: $viewModel.comment) | ||||
|                             .autocapitalization(.none) | ||||
|                             .disableAutocorrection(true) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                             .foregroundColor(Color(hex: "eeeeee")) | ||||
|                             .accentColor(Color(hex: "9970ff")) | ||||
|                             .keyboardType(.default) | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                          | ||||
|                         Spacer() | ||||
|                          | ||||
|                         Image("btn_message_send") | ||||
|                             .resizable() | ||||
|                             .frame(width: 35, height: 35) | ||||
|                             .padding(6.7) | ||||
|                             .onTapGesture { | ||||
|                                 hideKeyboard() | ||||
|                                 viewModel.registerComment() | ||||
|                             } | ||||
|                     } | ||||
|                     .background(Color(hex: "232323")) | ||||
|                     .cornerRadius(10) | ||||
|                     .overlay( | ||||
|                         RoundedRectangle(cornerRadius: 10) | ||||
|                             .strokeBorder(lineWidth: 1) | ||||
|                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                     ) | ||||
|                      | ||||
|                     Spacer() | ||||
|                 } | ||||
|                 .padding(.horizontal, 13.3) | ||||
|                  | ||||
|                 Rectangle() | ||||
|                     .foregroundColor(Color(hex: "595959")) | ||||
|                     .frame(height: 0.5) | ||||
|                     .padding(.top, 12) | ||||
|                     .padding(.bottom, 13.3) | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                  | ||||
|                 AudioContentCommentItemView(comment: parentComment, isReplyComment: true) | ||||
|                     .padding(.horizontal, 26.7) | ||||
|                     .padding(.bottom, 13.3) | ||||
|                  | ||||
|                 ScrollView(.vertical, showsIndicators: false) { | ||||
|                     LazyVStack(spacing: 13.3) { | ||||
|                         ForEach(0..<viewModel.commentList.count, id: \.self) { index in | ||||
|                             let comment = viewModel.commentList[index] | ||||
|                             AudioContentCommentItemView(comment: comment, isReplyComment: true) | ||||
|                                 .padding(.horizontal, 40) | ||||
|                                 .onAppear { | ||||
|                                     if index == viewModel.commentList.count - 1 { | ||||
|                                         viewModel.getCommentList() | ||||
|                                     } | ||||
|                                 } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .navigationTitle("") | ||||
|             .navigationBarBackButtonHidden() | ||||
|         } | ||||
|         .onAppear { | ||||
|             viewModel.audioContentId = audioContentId | ||||
|             viewModel.commentId = parentComment.id | ||||
|             viewModel.getCommentList() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,124 @@ | ||||
| // | ||||
| //  AudioContentListReplyViewModel.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import Combine | ||||
|  | ||||
| final class AudioContentListReplyViewModel: ObservableObject { | ||||
|      | ||||
|     private let repository = ContentRepository() | ||||
|     private var subscription = Set<AnyCancellable>() | ||||
|      | ||||
|     @Published var isLoading = false | ||||
|     @Published var errorMessage = "" | ||||
|     @Published var isShowPopup = false | ||||
|      | ||||
|     @Published var comment = "" | ||||
|     @Published var totalCommentCount = 0 | ||||
|     @Published var commentList = [GetAudioContentCommentListItem]() | ||||
|      | ||||
|     var audioContentId = 0 | ||||
|     var commentId = 0 | ||||
|     var page = 1 | ||||
|     var isLast = false | ||||
|     private let pageSize = 10 | ||||
|      | ||||
|     func getCommentList() { | ||||
|         if (!isLast && !isLoading) { | ||||
|             repository | ||||
|                 .getAudioContentCommentReplyList(commentId: commentId, page: page, size: pageSize) | ||||
|                 .sink { result in | ||||
|                     switch result { | ||||
|                     case .finished: | ||||
|                         DEBUG_LOG("finish") | ||||
|                     case .failure(let error): | ||||
|                         ERROR_LOG(error.localizedDescription) | ||||
|                     } | ||||
|                 } receiveValue: { [unowned self] response in | ||||
|                     let responseData = response.data | ||||
|                      | ||||
|                     do { | ||||
|                         let jsonDecoder = JSONDecoder() | ||||
|                         let decoded = try jsonDecoder.decode(ApiResponse<GetAudioContentCommentListResponse>.self, from: responseData) | ||||
|                          | ||||
|                         if let data = decoded.data, decoded.success { | ||||
|                             if page == 1 { | ||||
|                                 commentList.removeAll() | ||||
|                             } | ||||
|                              | ||||
|                             if !data.items.isEmpty { | ||||
|                                 page += 1 | ||||
|                                 self.totalCommentCount = data.totalCount | ||||
|                                 self.commentList.append(contentsOf: data.items) | ||||
|                             } else { | ||||
|                                 isLast = true | ||||
|                             } | ||||
|                         } else { | ||||
|                             if let message = decoded.message { | ||||
|                                 self.errorMessage = message | ||||
|                             } else { | ||||
|                                 self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                             } | ||||
|                              | ||||
|                             self.isShowPopup = true | ||||
|                         } | ||||
|                     } catch { | ||||
|                         self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                      | ||||
|                     self.isLoading = false | ||||
|                 } | ||||
|                 .store(in: &subscription) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func registerComment() { | ||||
|         if comment.trimmingCharacters(in: .whitespaces).isEmpty { | ||||
|             return | ||||
|         } | ||||
|          | ||||
|         isLoading = true | ||||
|          | ||||
|         repository.registerComment(audioContentId: audioContentId, comment: comment, parentId: commentId) | ||||
|             .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.comment = "" | ||||
|                         self.page = 1 | ||||
|                         self.isLast = false | ||||
|                         self.getCommentList() | ||||
|                     } 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) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,89 @@ | ||||
| // | ||||
| //  ContentDetailCommentView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct ContentDetailCommentView: View { | ||||
|      | ||||
|     let commentCount: Int | ||||
|     let commentList: [GetAudioContentCommentListItem] | ||||
|      | ||||
|     let registerComment: (String) -> Void | ||||
|      | ||||
|     @State private var comment = "" | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading, spacing: 10.3) { | ||||
|             HStack(spacing: 5.3) { | ||||
|                 Text("댓글") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                     .foregroundColor(.white) | ||||
|                  | ||||
|                 Text("\(commentCount)") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                     .foregroundColor(Color(hex: "909090")) | ||||
|                  | ||||
|                 Spacer() | ||||
|             } | ||||
|              | ||||
|             HStack(spacing: 8) { | ||||
|                 KFImage( | ||||
|                     URL( | ||||
|                         string: commentCount > 0 ? | ||||
|                         commentList[0].profileUrl : | ||||
|                             UserDefaults.string(forKey: .profileImage) | ||||
|                     ) | ||||
|                 ) | ||||
|                 .cancelOnDisappear(true) | ||||
|                 .downsampling(size: CGSize(width: 33.3, height: 33.3)) | ||||
|                 .resizable() | ||||
|                 .frame(width: 33.3, height: 33.3) | ||||
|                 .clipShape(Circle()) | ||||
|                  | ||||
|                 if commentCount > 0 { | ||||
|                     Text(commentList[0].comment) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                         .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                         .lineLimit(1) | ||||
|                         .padding(.leading, 3) | ||||
|                 } else { | ||||
|                     HStack(spacing: 0) { | ||||
|                         TextField("댓글을 입력해 보세요.", text: $comment) | ||||
|                             .autocapitalization(.none) | ||||
|                             .disableAutocorrection(true) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                             .foregroundColor(Color(hex: "eeeeee")) | ||||
|                             .accentColor(Color(hex: "9970ff")) | ||||
|                             .keyboardType(.default) | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                          | ||||
|                         Spacer() | ||||
|                          | ||||
|                         Image("btn_message_send") | ||||
|                             .resizable() | ||||
|                             .frame(width: 35, height: 35) | ||||
|                             .padding(6.7) | ||||
|                             .onTapGesture { | ||||
|                                 hideKeyboard() | ||||
|                                 registerComment(comment) | ||||
|                             } | ||||
|                     } | ||||
|                     .background(Color(hex: "232323")) | ||||
|                     .cornerRadius(10) | ||||
|                     .overlay( | ||||
|                         RoundedRectangle(cornerRadius: 10) | ||||
|                             .strokeBorder(lineWidth: 1) | ||||
|                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                     ) | ||||
|                 } | ||||
|                  | ||||
|                 Spacer() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -18,7 +18,7 @@ struct GetAudioContentCommentListItem: Decodable { | ||||
|     let nickname: String | ||||
|     let profileUrl: String | ||||
|     let comment: String | ||||
|     let donationCoin: Int | ||||
|     let donationCan: Int | ||||
|     let date: String | ||||
|     let replyCount: Int | ||||
| } | ||||
|   | ||||
| @@ -9,6 +9,6 @@ import Foundation | ||||
|  | ||||
| struct RegisterAudioContentCommentRequest: Encodable { | ||||
|     let comment: String | ||||
|     let audioContentId: Int | ||||
|     let contentId: Int | ||||
|     let parentId: Int? | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,35 @@ | ||||
| // | ||||
| //  ContentDetailAnotherItemView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct ContentDetailAnotherItemView: View { | ||||
|      | ||||
|     let item: OtherContentResponse | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading, spacing: 8) { | ||||
|             KFImage(URL(string: item.coverUrl)) | ||||
|                 .resizable() | ||||
|                 .frame(width: 93.3, height: 93.3, alignment: .center) | ||||
|                 .clipped() | ||||
|                 .cornerRadius(2.7) | ||||
|              | ||||
|             HStack(spacing: 0) { | ||||
|                 Text(item.title) | ||||
|                     .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                     .foregroundColor(Color(hex: "777777")) | ||||
|                     .multilineTextAlignment(.leading) | ||||
|                     .lineLimit(1) | ||||
|                  | ||||
|                 Spacer() | ||||
|             } | ||||
|         } | ||||
|         .frame(maxWidth: 93.3) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,43 @@ | ||||
| // | ||||
| //  ContentDetailCreatorProfileView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/11. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct ContentDetailCreatorProfileView: View { | ||||
|      | ||||
|     let creator: AudioContentCreator | ||||
|     let onClickFollow: (Int) -> Void | ||||
|     let onClickUnFollow: (Int) -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         HStack(spacing: 0) { | ||||
|             KFImage(URL(string: creator.profileImageUrl)) | ||||
|                 .resizable() | ||||
|                 .frame(width: 26.7, height: 26.7) | ||||
|                 .clipShape(Circle()) | ||||
|              | ||||
|             Text(creator.nickname) | ||||
|                 .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                 .foregroundColor(Color(hex: "777777")) | ||||
|                 .padding(.horizontal, 5.3) | ||||
|              | ||||
|             Spacer() | ||||
|              | ||||
|             if creator.creatorId != UserDefaults.int(forKey: .userId) { | ||||
|                 Image(creator.isFollowing ? "btn_notification_selected" : "btn_notification") | ||||
|                     .onTapGesture { | ||||
|                         if creator.isFollowing { | ||||
|                             onClickUnFollow(creator.creatorId) | ||||
|                         } else { | ||||
|                             onClickFollow(creator.creatorId) | ||||
|                         } | ||||
|                     } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										185
									
								
								SodaLive/Sources/Content/Detail/ContentDetailInfoView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,185 @@ | ||||
| // | ||||
| //  ContentDetailInfoView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct ContentDetailInfoView: View { | ||||
|      | ||||
|     @Binding var isExpandDescription: Bool | ||||
|     @Binding var isShowPreviewAlert: Bool | ||||
|      | ||||
|     let audioContent: GetAudioContentDetailResponse | ||||
|     let onClickLike: () -> Void | ||||
|     let onClickShare: () -> Void | ||||
|     let onClickDonation: () -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading, spacing: 0) { | ||||
|             VStack(alignment: .leading, spacing: 8) { | ||||
|                 HStack(spacing: 5.3) { | ||||
|                     Text(audioContent.themeStr) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                         .foregroundColor(Color(hex: "3bac6a")) | ||||
|                         .padding(.horizontal, 5.3) | ||||
|                         .padding(.vertical, 3.3) | ||||
|                         .background(Color(hex: "28312b")) | ||||
|                         .cornerRadius(2.6) | ||||
|                      | ||||
|                     if audioContent.isAdult { | ||||
|                         Text("19") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                             .foregroundColor(Color(hex: "e33621")) | ||||
|                             .padding(.horizontal, 5.3) | ||||
|                             .padding(.vertical, 3.3) | ||||
|                             .background(Color(hex: "601d14")) | ||||
|                             .cornerRadius(2.6) | ||||
|                     } | ||||
|                      | ||||
|                     Spacer() | ||||
|                      | ||||
|                     if let orderType = audioContent.orderType, audioContent.existOrdered { | ||||
|                         if let remainingTime = audioContent.remainingTime, orderType == .RENTAL { | ||||
|                             HStack(spacing: 2.7) { | ||||
|                                 Image("ic_time_l") | ||||
|                                  | ||||
|                                 Text(remainingTime) | ||||
|                                     .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                                     .foregroundColor(Color(hex: "909090")) | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         Text(orderType == .KEEP ? "소장중" : "대여중") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                             .foregroundColor( | ||||
|                                 orderType == .KEEP ? | ||||
|                                 Color(hex: "b1ef2c") : | ||||
|                                     Color(hex: "9970ff") | ||||
|                             ) | ||||
|                             .padding(.horizontal, 5.3) | ||||
|                             .padding(.vertical, 3.3) | ||||
|                             .background( | ||||
|                                 orderType == .KEEP ? | ||||
|                                 Color(hex: "26310f") : | ||||
|                                     Color(hex: "30176f") | ||||
|                             ) | ||||
|                             .cornerRadius(2.6) | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 Text(audioContent.title) | ||||
|                     .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                     .foregroundColor(Color(hex: "d2d2d2")) | ||||
|                     .lineSpacing(5) | ||||
|                     .multilineTextAlignment(.leading) | ||||
|                     .fixedSize(horizontal: false, vertical: true) | ||||
|                     .frame(maxWidth: .infinity, alignment: .leading) | ||||
|             } | ||||
|             .padding(.top, 13.3) | ||||
|              | ||||
|             ScrollView(.horizontal, showsIndicators: false) { | ||||
|                 HStack(spacing: 8) { | ||||
|                     HStack(spacing: 4) { | ||||
|                         Image( | ||||
|                             audioContent.isLike ? | ||||
|                             "ic_audio_content_heart_pressed" : | ||||
|                                 "ic_audio_content_heart_normal" | ||||
|                         ) | ||||
|                          | ||||
|                         Text("\(audioContent.likeCount)") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                             .foregroundColor(Color(hex: "d2d2d2")) | ||||
|                     } | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                     .padding(.vertical, 5.3) | ||||
|                     .background(Color(hex: "ffffff").opacity(0.1)) | ||||
|                     .cornerRadius(26.7) | ||||
|                     .onTapGesture { onClickLike() } | ||||
|                      | ||||
|                     HStack(spacing: 4) { | ||||
|                         Image("ic_audio_content_share") | ||||
|                          | ||||
|                         Text("공유") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                             .foregroundColor(Color(hex: "d2d2d2")) | ||||
|                     } | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                     .padding(.vertical, 5.3) | ||||
|                     .background(Color(hex: "ffffff").opacity(0.1)) | ||||
|                     .cornerRadius(26.7) | ||||
|                     .onTapGesture { onClickShare() } | ||||
|                      | ||||
|                     if audioContent.isCommentAvailable { | ||||
|                         HStack(spacing: 4) { | ||||
|                             Image("ic_donation_white") | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 13.3, height: 13.3) | ||||
|                              | ||||
|                             Text("후원") | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                                 .foregroundColor(Color(hex: "d2d2d2")) | ||||
|                         } | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                         .padding(.vertical, 5.3) | ||||
|                         .background(Color(hex: "ffffff").opacity(0.1)) | ||||
|                         .cornerRadius(26.7) | ||||
|                         .onTapGesture { onClickDonation() } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .padding(.top, 13.3) | ||||
|              | ||||
|             ZStack { | ||||
|                 VStack(spacing: 8) { | ||||
|                     if audioContent.tag.count > 0 { | ||||
|                         Text(audioContent.tag) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                             .frame(maxWidth: .infinity, alignment: .leading) | ||||
|                     } | ||||
|                      | ||||
|                     Text(audioContent.detail) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                         .foregroundColor(Color(hex: "777777")) | ||||
|                         .lineLimit(isExpandDescription ? nil : 3) | ||||
|                         .lineSpacing(5) | ||||
|                         .frame(maxWidth: .infinity, alignment: .leading) | ||||
|                         .onTapGesture { isExpandDescription.toggle() } | ||||
|                 } | ||||
|                 .padding(.top, 13.3) | ||||
|                  | ||||
|                 if isShowingPreviewAlert() { | ||||
|                     HStack(spacing: 0) { | ||||
|                         Text("미리듣기 중입니다.\n콘텐츠 구매 후 전체를 감상해 보세요.") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                             .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                             .lineSpacing(5) | ||||
|                          | ||||
|                         Spacer() | ||||
|                          | ||||
|                         Image("ic_circle_x_white") | ||||
|                             .onTapGesture { isShowPreviewAlert = false } | ||||
|                     } | ||||
|                     .padding(13.3) | ||||
|                     .background(Color(hex: "1e0e45").opacity(0.89)) | ||||
|                     .cornerRadius(5.3) | ||||
|                     .overlay( | ||||
|                         RoundedRectangle(cornerRadius: 5.3) | ||||
|                             .stroke(lineWidth: 1) | ||||
|                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func isShowingPreviewAlert() -> Bool { | ||||
|         return isShowPreviewAlert && | ||||
|         audioContent.creator.creatorId != UserDefaults.int(forKey: .userId) && | ||||
|         !audioContent.existOrdered && | ||||
|         audioContent.price > 0 | ||||
|     } | ||||
| } | ||||
							
								
								
									
										83
									
								
								SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,83 @@ | ||||
| // | ||||
| //  ContentDetailMenuView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct ContentDetailMenuView: View { | ||||
|      | ||||
|     @Binding var isShowing: Bool | ||||
|      | ||||
|     let isShowCreatorMenu: Bool | ||||
|     let modifyAction: () -> Void | ||||
|     let deleteAction: () -> Void | ||||
|     let reportAction: () -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             Color.black | ||||
|                 .opacity(0.7) | ||||
|                 .ignoresSafeArea() | ||||
|                 .onTapGesture { isShowing = false } | ||||
|              | ||||
|             VStack(spacing: 0) { | ||||
|                 Spacer() | ||||
|                  | ||||
|                 VStack(spacing: 13.3) { | ||||
|                     if isShowCreatorMenu { | ||||
|                         HStack(spacing: 0) { | ||||
|                             Text("수정") | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 16.7)) | ||||
|                                 .foregroundColor(.white) | ||||
|                              | ||||
|                             Spacer() | ||||
|                         } | ||||
|                         .padding(.vertical, 8) | ||||
|                         .padding(.horizontal, 26.7) | ||||
|                         .contentShape(Rectangle()) | ||||
|                         .onTapGesture { | ||||
|                             isShowing = false | ||||
|                             modifyAction() | ||||
|                         } | ||||
|                          | ||||
|                         HStack(spacing: 0) { | ||||
|                             Text("삭제") | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 16.7)) | ||||
|                                 .foregroundColor(.white) | ||||
|                              | ||||
|                             Spacer() | ||||
|                         } | ||||
|                         .padding(.vertical, 8) | ||||
|                         .padding(.horizontal, 26.7) | ||||
|                         .contentShape(Rectangle()) | ||||
|                         .onTapGesture { | ||||
|                             isShowing = false | ||||
|                             deleteAction() | ||||
|                         } | ||||
|                     } else { | ||||
|                         HStack(spacing: 0) { | ||||
|                             Text("신고") | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 16.7)) | ||||
|                                 .foregroundColor(.white) | ||||
|                              | ||||
|                             Spacer() | ||||
|                         } | ||||
|                         .padding(.vertical, 8) | ||||
|                         .padding(.horizontal, 26.7) | ||||
|                         .contentShape(Rectangle()) | ||||
|                         .onTapGesture { | ||||
|                             isShowing = false | ||||
|                             reportAction() | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 .padding(24) | ||||
|                 .background(Color(hex: "222222")) | ||||
|                 .cornerRadius(13.3, corners: [.topLeft, .topRight]) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,45 @@ | ||||
| // | ||||
| //  ContentDetailMosaicView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct ContentDetailMosaicView: View { | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             Color.black.opacity(0.8) | ||||
|              | ||||
|             VStack(spacing: 0) { | ||||
|                 VStack(spacing: 0) { | ||||
|                     Image("ic_notice_exclamation_mark") | ||||
|                      | ||||
|                     Text("본 콘텐츠는 만 19세 미만의 청소년이\n이용할 수 없습니다.\n본인인증 후 콘텐츠를 이용해 주세요.") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 18.7)) | ||||
|                         .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                         .padding(.top, 21.7) | ||||
|                      | ||||
|                     Text("본인인증") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 18.7)) | ||||
|                         .foregroundColor(Color.white) | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                         .padding(.vertical, 8) | ||||
|                         .overlay( | ||||
|                             RoundedRectangle(cornerRadius: 26.7) | ||||
|                                 .stroke(lineWidth: 1) | ||||
|                                 .foregroundColor(Color.white.opacity(0.15)) | ||||
|                         ) | ||||
|                         .padding(.top, 26.7) | ||||
|                 } | ||||
|                 .frame(width: screenSize().width - 26.7, height: screenSize().width - 26.7) | ||||
|                 .background(Color(hex: "222222")) | ||||
|                 .cornerRadius(10) | ||||
|                 .padding(.top, 13.3) | ||||
|                  | ||||
|                 Spacer() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,51 @@ | ||||
| // | ||||
| //  ContentDetailOtherContentView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct ContentDetailOtherContentView: View { | ||||
|      | ||||
|     let title: String | ||||
|     let items: [OtherContentResponse] | ||||
|     let onClickItem: (Int) -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(spacing: 21.3) { | ||||
|             Text(title) | ||||
|                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .frame(maxWidth: .infinity, alignment: .leading) | ||||
|              | ||||
|             if items.count > 0 { | ||||
|                 ScrollView(.horizontal, showsIndicators: false) { | ||||
|                     HStack(spacing: 13.3) { | ||||
|                         ForEach(0..<items.count, id: \.self) { index in | ||||
|                             let item = items[index] | ||||
|                             ContentDetailAnotherItemView(item: item) | ||||
|                                 .onTapGesture { onClickItem(item.contentId) } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } else { | ||||
|                 VStack(spacing: 6.7) { | ||||
|                     Image("ic_no_item") | ||||
|                         .resizable() | ||||
|                         .frame(width: 60, height: 60) | ||||
|                      | ||||
|                     Text("\(title)를 준비중입니다.\n조금만 기다려주세요.") | ||||
|                         .multilineTextAlignment(.center) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 10.7)) | ||||
|                         .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                 } | ||||
|                 .padding(13.3) | ||||
|                 .frame(maxWidth: .infinity) | ||||
|                 .background(Color(hex: "2b2635")) | ||||
|                 .cornerRadius(4.7) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										171
									
								
								SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,171 @@ | ||||
| // | ||||
| //  ContentDetailPlayView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
| import Sliders | ||||
|  | ||||
| struct ContentDetailPlayView: View { | ||||
|      | ||||
|     let audioContent: GetAudioContentDetailResponse | ||||
|     @Binding var isShowPreviewAlert: Bool | ||||
|      | ||||
|     @StateObject var contentPlayManager = ContentPlayManager.shared | ||||
|      | ||||
|     @State private var isRepeat = UserDefaults.bool(forKey: .isContentPlayLoop) | ||||
|     @State private var isEditing = false | ||||
|     @State private var progress: TimeInterval = 0 | ||||
|     @State private var timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect() | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading, spacing: 8) { | ||||
|             ZStack { | ||||
|                 KFImage(URL(string: audioContent.coverImageUrl)) | ||||
|                     .resizable() | ||||
|                     .scaledToFill() | ||||
|                     .frame( | ||||
|                         width: screenSize().width - 26.7, | ||||
|                         height: screenSize().width - 26.7, | ||||
|                         alignment: .center | ||||
|                     ) | ||||
|                     .cornerRadius(10.7, corners: [.topLeft, .topRight]) | ||||
|                  | ||||
|                 Image(isPlaying() ? "btn_audio_content_pause" : "btn_audio_content_play") | ||||
|                     .onTapGesture { | ||||
|                         if isPlaying() { | ||||
|                             contentPlayManager.pauseAudio() | ||||
|                         } else { | ||||
|                             contentPlayManager.startTimer = startTimer | ||||
|                             contentPlayManager.stopTimer = stopTimer | ||||
|                              | ||||
|                             contentPlayManager.playAudio( | ||||
|                                 contentId: audioContent.contentId, | ||||
|                                 title: audioContent.title, | ||||
|                                 nickname: audioContent.creator.nickname, | ||||
|                                 coverImage: audioContent.coverImageUrl, | ||||
|                                 contentUrl: audioContent.contentUrl, | ||||
|                                 isFree: audioContent.price <= 0, | ||||
|                                 isPreview: !audioContent.existOrdered && audioContent.price > 0 | ||||
|                             ) | ||||
|                             isShowPreviewAlert = true | ||||
|                         } | ||||
|                     } | ||||
|                  | ||||
|                 VStack(alignment: .leading, spacing: 13.3) { | ||||
|                     Spacer() | ||||
|                      | ||||
|                     ValueSlider( | ||||
|                         value: audioContent.contentId == contentPlayManager.contentId ? $progress : .constant(0), | ||||
|                         in: sliderRange(), | ||||
|                         onEditingChanged: { editing in | ||||
|                             isEditing = editing | ||||
|                             if !editing { | ||||
|                                 contentPlayManager.setCurrentTime(progress) | ||||
|                             } | ||||
|                         } | ||||
|                     ) | ||||
|                     .valueSliderStyle( | ||||
|                         HorizontalValueSliderStyle( | ||||
|                             track: HorizontalValueTrack( | ||||
|                                 view: Rectangle().foregroundColor(Color(hex: "9970ff")), | ||||
|                                 mask: Rectangle() | ||||
|                             ) | ||||
|                             .background(Rectangle().foregroundColor(Color(hex: "979797").opacity(0.3))) | ||||
|                             .frame(height: 5.3), | ||||
|                             thumbSize: CGSizeZero, | ||||
|                             options: .interactiveTrack | ||||
|                         ) | ||||
|                     ) | ||||
|                     .frame(height: 5.3) | ||||
|                 } | ||||
|                  | ||||
|                 if contentPlayManager.isLoading { | ||||
|                     LoadingView() | ||||
|                 } | ||||
|             } | ||||
|             .frame( | ||||
|                 width: screenSize().width - 26.7, | ||||
|                 height: screenSize().width - 26.7 | ||||
|             ) | ||||
|              | ||||
|             HStack(spacing: 0) { | ||||
|                 Text("\(getProgress()) / \(getDuration())") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                     .foregroundColor(.white) | ||||
|                  | ||||
|                 Spacer() | ||||
|                  | ||||
|                 Image( | ||||
|                     isRepeat ? | ||||
|                     "btn_player_repeat" : | ||||
|                         "btn_player_repeat_done" | ||||
|                 ) | ||||
|                 .onTapGesture { | ||||
|                     isRepeat = !UserDefaults.bool(forKey: .isContentPlayLoop) | ||||
|                     UserDefaults.set( | ||||
|                         isRepeat, | ||||
|                         forKey: .isContentPlayLoop | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|             .frame(width: screenSize().width - 40) | ||||
|         } | ||||
|         .onAppear { | ||||
|             if !isPlaying() { | ||||
|                 stopTimer() | ||||
|             } | ||||
|         } | ||||
|         .onReceive(timer) { _ in | ||||
|             guard let player = contentPlayManager.player, !isEditing else { return } | ||||
|             self.progress = player.currentTime | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func isPlaying() -> Bool { | ||||
|         return contentPlayManager.contentId == audioContent.contentId && contentPlayManager.isPlaying | ||||
|     } | ||||
|      | ||||
|     private func sliderRange() -> ClosedRange<Double> { | ||||
|         if audioContent.contentId == contentPlayManager.contentId { | ||||
|             return 0...contentPlayManager.duration | ||||
|         } else { | ||||
|             return 0...0 | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func getProgress() -> String { | ||||
|         if audioContent.contentId == contentPlayManager.contentId { | ||||
|             return secondsToMinutesSeconds(seconds: Int(progress)) | ||||
|         } else { | ||||
|             return secondsToMinutesSeconds(seconds: 0) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func getDuration() -> String { | ||||
|         if audioContent.contentId == contentPlayManager.contentId { | ||||
|             return secondsToMinutesSeconds(seconds: Int(contentPlayManager.duration)) | ||||
|         } else { | ||||
|             return audioContent.duration | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func secondsToMinutesSeconds(seconds: Int) -> String { | ||||
|         let hours = String(format: "%02d", seconds / 3600) | ||||
|         let minute = String(format: "%02d", (seconds % 3600) / 60) | ||||
|         let second = String(format: "%02d", seconds % 60) | ||||
|          | ||||
|         return "\(hours):\(minute):\(second)" | ||||
|     } | ||||
|      | ||||
|     private func startTimer() { | ||||
|         timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() | ||||
|     } | ||||
|      | ||||
|     private func stopTimer() { | ||||
|         timer.upstream.connect().cancel() | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,39 @@ | ||||
| // | ||||
| //  ContentDetailPurchaseButton.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct ContentDetailPurchaseButton: View { | ||||
|      | ||||
|     let price: Int | ||||
|      | ||||
|     var body: some View { | ||||
|         HStack(spacing: 0) { | ||||
|             Image("ic_can") | ||||
|                 .resizable() | ||||
|                 .frame(width: 16.7, height: 16.7) | ||||
|              | ||||
|             Text("\(price)") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                 .foregroundColor(.white) | ||||
|                 .padding(.leading, 5.3) | ||||
|              | ||||
|             Text("캔으로") | ||||
|                 .font(.custom(Font.light.rawValue, size: 12)) | ||||
|                 .foregroundColor(.white) | ||||
|              | ||||
|             Text(" 구매하기") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                 .foregroundColor(.white) | ||||
|         } | ||||
|         .frame(maxWidth: .infinity) | ||||
|         .frame(height: 48.7) | ||||
|         .background(Color(hex: "9970ff")) | ||||
|         .cornerRadius(5.3) | ||||
|         .padding(.top, 18.3) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										308
									
								
								SodaLive/Sources/Content/Detail/ContentDetailView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,308 @@ | ||||
| // | ||||
| //  ContentDetailView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/11. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
| import RefreshableScrollView | ||||
|  | ||||
| struct ContentDetailView: View { | ||||
|      | ||||
|     let contentId: Int | ||||
|     @StateObject private var viewModel = ContentDetailViewModel() | ||||
|      | ||||
|     @State private var isShowOrderView = false | ||||
|     @State private var isShowOrderConfirmView = false | ||||
|     @State private var isShowCommentListView = false | ||||
|      | ||||
|     var body: some View { | ||||
|         GeometryReader { proxy in | ||||
|             BaseView(isLoading: $viewModel.isLoading) { | ||||
|                 VStack(spacing: 0) { | ||||
|                     HStack(spacing: 0) { | ||||
|                         Button { | ||||
|                             AppState.shared.back() | ||||
|                         } label: { | ||||
|                             Image("ic_back") | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 20, height: 20) | ||||
|                              | ||||
|                             Text("콘텐츠 상세") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                         } | ||||
|                          | ||||
|                         Spacer() | ||||
|                          | ||||
|                         Image("ic_seemore_vertical") | ||||
|                             .onTapGesture { | ||||
|                                 viewModel.isShowReportMenu = true | ||||
|                             } | ||||
|                     } | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                     .frame(height: 50) | ||||
|                     .background(Color.black) | ||||
|                      | ||||
|                     if let audioContent = viewModel.audioContent { | ||||
|                         ContentDetailCreatorProfileView( | ||||
|                             creator: audioContent.creator, | ||||
|                             onClickFollow: { viewModel.creatorFollow(userId: $0) }, | ||||
|                             onClickUnFollow: { viewModel.creatorUnFollow(userId: $0) } | ||||
|                         ) | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                         .padding(.top, 5.3) | ||||
|                         .onTapGesture { | ||||
|                             AppState.shared | ||||
|                                 .setAppStep(step: .creatorDetail(userId: audioContent.creator.creatorId)) | ||||
|                         } | ||||
|                          | ||||
|                         ZStack { | ||||
|                             RefreshableScrollView( | ||||
|                                 refreshing: $viewModel.isLoading, | ||||
|                                 action: { | ||||
|                                     viewModel.getAudioContentDetail() | ||||
|                                 }) { | ||||
|                                     VStack(spacing: 0) { | ||||
|                                         LazyVStack(spacing: 0) { | ||||
|                                             ContentDetailPlayView( | ||||
|                                                 audioContent: audioContent, | ||||
|                                                 isShowPreviewAlert: $viewModel.isShowPreviewAlert | ||||
|                                             ) | ||||
|                                              | ||||
|                                             ContentDetailInfoView( | ||||
|                                                 isExpandDescription: $viewModel.isExpandDescription, | ||||
|                                                 isShowPreviewAlert: $viewModel.isShowPreviewAlert, | ||||
|                                                 audioContent: audioContent, | ||||
|                                                 onClickLike: { viewModel.likeContent() }, | ||||
|                                                 onClickShare: { | ||||
|                                                     viewModel.shareAudioContent( | ||||
|                                                         contentImage: audioContent.coverImageUrl, | ||||
|                                                         contentTitle: "\(audioContent.title) - \(audioContent.creator.nickname)" | ||||
|                                                     ) | ||||
|                                                 }, | ||||
|                                                 onClickDonation: { viewModel.isShowDonationPopup = true } | ||||
|                                             ) | ||||
|                                              | ||||
|                                             if audioContent.price > 0 && | ||||
|                                                 !audioContent.existOrdered && | ||||
|                                                 audioContent.orderType == nil && | ||||
|                                                 audioContent.creator.creatorId != UserDefaults.int(forKey: .userId) { | ||||
|                                                 ContentDetailPurchaseButton(price: audioContent.price) | ||||
|                                                     .contentShape(Rectangle()) | ||||
|                                                     .onTapGesture { isShowOrderView = true } | ||||
|                                             } | ||||
|                                              | ||||
|                                             if audioContent.isCommentAvailable { | ||||
|                                                 ContentDetailCommentView( | ||||
|                                                     commentCount: audioContent.commentCount, | ||||
|                                                     commentList: audioContent.commentList, | ||||
|                                                     registerComment: { comment in | ||||
|                                                         self.viewModel.registerComment(comment: comment) | ||||
|                                                     } | ||||
|                                                 ) | ||||
|                                                 .padding(10.3) | ||||
|                                                 .background(Color.white.opacity(0.1)) | ||||
|                                                 .cornerRadius(5.3) | ||||
|                                                 .padding(.top, 13.3) | ||||
|                                                 .contentShape(Rectangle()) | ||||
|                                                 .onTapGesture { | ||||
|                                                     if audioContent.commentCount > 0 { | ||||
|                                                         isShowCommentListView = true | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                             } | ||||
|                                         } | ||||
|                                         .padding(.horizontal, 13.3) | ||||
|                                          | ||||
|                                         Rectangle() | ||||
|                                             .foregroundColor(Color(hex: "232323")) | ||||
|                                             .frame(height: 6.7) | ||||
|                                             .padding(.top, 24) | ||||
|                                          | ||||
|                                         ContentDetailOtherContentView( | ||||
|                                             title: "크리에이터의 다른 콘텐츠", | ||||
|                                             items: audioContent.creatorOtherContentList, | ||||
|                                             onClickItem: { viewModel.contentId = $0 } | ||||
|                                         ) | ||||
|                                         .padding(.top, 26.7) | ||||
|                                         .padding(.horizontal, 13.3) | ||||
|                                          | ||||
|                                         Rectangle() | ||||
|                                             .foregroundColor(Color(hex: "232323")) | ||||
|                                             .frame(height: 6.7) | ||||
|                                             .padding(.top, 24) | ||||
|                                          | ||||
|                                         ContentDetailOtherContentView( | ||||
|                                             title: "테마의 다른 콘텐츠", | ||||
|                                             items: audioContent.sameThemeOtherContentList, | ||||
|                                             onClickItem: { viewModel.contentId = $0 } | ||||
|                                         ) | ||||
|                                         .padding(.top, 26.7) | ||||
|                                         .padding(.horizontal, 13.3) | ||||
|                                     } | ||||
|                                 } | ||||
|                              | ||||
|                             if audioContent.isMosaic { | ||||
|                                 ContentDetailMosaicView() | ||||
|                             } | ||||
|                         } | ||||
|                         .padding(.top, 13.3) | ||||
|                          | ||||
|                     } | ||||
|                      | ||||
|                     Spacer() | ||||
|                 } | ||||
|                 .onAppear { | ||||
|                     viewModel.contentId = contentId | ||||
|                     AppState.shared.pushAudioContentId = 0 | ||||
|                 } | ||||
|                  | ||||
|                 if let audioContent = viewModel.audioContent, isShowOrderView { | ||||
|                     VStack(spacing: 0) { | ||||
|                         ContentOrderDialogView( | ||||
|                             isShowing: $isShowOrderView, | ||||
|                             price: audioContent.price, | ||||
|                             onTapPurchase: { | ||||
|                                 viewModel.orderType = $0 | ||||
|                                 isShowOrderConfirmView = true | ||||
|                             } | ||||
|                         ) | ||||
|                          | ||||
|                         if proxy.safeAreaInsets.bottom > 0 { | ||||
|                             Rectangle() | ||||
|                                 .foregroundColor(Color(hex: "222222")) | ||||
|                                 .frame(width: proxy.size.width, height: 15.3) | ||||
|                         } | ||||
|                     } | ||||
|                     .ignoresSafeArea() | ||||
|                 } | ||||
|                  | ||||
|                 if | ||||
|                     let orderType = viewModel.orderType, | ||||
|                     let audioContent = viewModel.audioContent, | ||||
|                     isShowOrderConfirmView | ||||
|                 { | ||||
|                     VStack(spacing: 0) { | ||||
|                         ContentOrderConfirmDialogView( | ||||
|                             isShowing: $isShowOrderConfirmView, | ||||
|                             audioContent: audioContent, | ||||
|                             orderType: orderType, | ||||
|                             onClickConfirm: { | ||||
|                                 viewModel.order(orderType: orderType) | ||||
|                             } | ||||
|                         ) | ||||
|                     } | ||||
|                     .ignoresSafeArea() | ||||
|                 } | ||||
|                  | ||||
|                 ZStack { | ||||
|                     if viewModel.isShowReportMenu { | ||||
|                         VStack(spacing: 0) { | ||||
|                             ContentDetailMenuView( | ||||
|                                 isShowing: $viewModel.isShowReportMenu, | ||||
|                                 isShowCreatorMenu: viewModel.audioContent!.creator.creatorId == UserDefaults.int(forKey: .userId), | ||||
|                                 modifyAction: { | ||||
|                                     if viewModel.audioContent!.creator.creatorId == UserDefaults.int(forKey: .userId) { | ||||
|                                         AppState | ||||
|                                             .shared | ||||
|                                             .setAppStep( | ||||
|                                                 step: .modifyContent(contentId: contentId) | ||||
|                                             ) | ||||
|                                     } | ||||
|                                 }, | ||||
|                                 deleteAction: { | ||||
|                                     if viewModel.audioContent!.creator.creatorId == UserDefaults.int(forKey: .userId) { | ||||
|                                         viewModel.isShowDeleteConfirm = true | ||||
|                                     } | ||||
|                                 }, | ||||
|                                 reportAction: { | ||||
|                                     viewModel.isShowReportView = true | ||||
|                                 } | ||||
|                             ) | ||||
|                              | ||||
|                             if proxy.safeAreaInsets.bottom > 0 { | ||||
|                                 Rectangle() | ||||
|                                     .foregroundColor(Color(hex: "222222")) | ||||
|                                     .frame(width: proxy.size.width, height: 15.3) | ||||
|                             } | ||||
|                         } | ||||
|                         .ignoresSafeArea() | ||||
|                     } | ||||
|                      | ||||
|                     if viewModel.isShowReportView { | ||||
|                         AudioContentReportDialogView( | ||||
|                             isShowing: $viewModel.isShowReportView, | ||||
|                             confirmAction: { reason in | ||||
|                                 viewModel.report( | ||||
|                                     type: .AUDIO_CONTENT, | ||||
|                                     audioContentId: contentId, | ||||
|                                     reason: reason | ||||
|                                 ) | ||||
|                             } | ||||
|                         ) | ||||
|                     } | ||||
|                      | ||||
|                     if viewModel.isShowDeleteConfirm { | ||||
|                         AudioContentDeleteDialogView( | ||||
|                             isShowing: $viewModel.isShowDeleteConfirm, | ||||
|                             title: viewModel.audioContent!.title, | ||||
|                             confirmAction: { | ||||
|                                 viewModel.deleteAudioContent { | ||||
|                                     DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { | ||||
|                                         AppState.shared.back() | ||||
|                                     } | ||||
|                                 } | ||||
|                             }, | ||||
|                             showToast: { | ||||
|                                 viewModel.errorMessage = "동의하셔야 삭제할 수 있습니다." | ||||
|                                 viewModel.isShowPopup = true | ||||
|                             } | ||||
|                         ) | ||||
|                     } | ||||
|                      | ||||
|                     if viewModel.isShowDonationPopup { | ||||
|                         LiveRoomDonationDialogView(isShowing: $viewModel.isShowDonationPopup, isAudioContentDonation: true) { can, comment in | ||||
|                             viewModel.donation(can: can, comment: comment) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .sheet( | ||||
|                 isPresented: $viewModel.isShowShareView, | ||||
|                 onDismiss: { viewModel.shareMessage = "" }, | ||||
|                 content: { | ||||
|                     ActivityViewController(activityItems: [viewModel.shareMessage]) | ||||
|                 } | ||||
|             ) | ||||
|             .sheet( | ||||
|                 isPresented: $isShowCommentListView, | ||||
|                 content: { | ||||
|                     AudioContentCommentListView( | ||||
|                         isPresented: $isShowCommentListView, | ||||
|                         audioContentId: viewModel.audioContent!.contentId | ||||
|                     ) | ||||
|                 } | ||||
|             ) | ||||
|             .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { | ||||
|                 GeometryReader { geo in | ||||
|                     HStack { | ||||
|                         Spacer() | ||||
|                         Text(viewModel.errorMessage) | ||||
|                             .padding(.vertical, 13.3) | ||||
|                             .frame(width: screenSize().width - 66.7, alignment: .center) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                             .background(Color(hex: "9970ff")) | ||||
|                             .foregroundColor(Color.white) | ||||
|                             .multilineTextAlignment(.center) | ||||
|                             .cornerRadius(20) | ||||
|                             .padding(.top, 66.7) | ||||
|                         Spacer() | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										446
									
								
								SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,446 @@ | ||||
| // | ||||
| //  ContentDetailViewModel.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/11. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import Combine | ||||
|  | ||||
| import FirebaseDynamicLinks | ||||
|  | ||||
| final class ContentDetailViewModel: ObservableObject { | ||||
|      | ||||
|     private let repository = ContentRepository() | ||||
|     private let reportRepository = ReportRepository() | ||||
|     private var userRepository = UserRepository() | ||||
|     private var subscription = Set<AnyCancellable>() | ||||
|      | ||||
|     @Published var errorMessage = "" | ||||
|     @Published var isShowPopup = false | ||||
|     @Published var isLoading = false | ||||
|      | ||||
|     @Published var isShowPreviewAlert = false | ||||
|     @Published var isExpandDescription = false | ||||
|     @Published var isShowDonationPopup = false | ||||
|      | ||||
|     @Published var isShowShareView = false | ||||
|     @Published var shareMessage = "" | ||||
|      | ||||
|     @Published private(set) var audioContent: GetAudioContentDetailResponse? | ||||
|     @Published var orderType: OrderType? | ||||
|      | ||||
|     @Published var isShowReportMenu = false | ||||
|     @Published var isShowReportView = false | ||||
|     @Published var isShowDeleteConfirm = false | ||||
|      | ||||
|     var contentId: Int = 0 { | ||||
|         didSet { | ||||
|             getAudioContentDetail() | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func getAudioContentDetail() { | ||||
|         audioContent = nil | ||||
|         isLoading = true | ||||
|          | ||||
|         repository.getAudioContentDetail(audioContentId: contentId) | ||||
|             .sink { result in | ||||
|                 switch result { | ||||
|                 case .finished: | ||||
|                     DEBUG_LOG("finish") | ||||
|                 case .failure(let error): | ||||
|                     ERROR_LOG(error.localizedDescription) | ||||
|                 } | ||||
|             } receiveValue: { [unowned self] response in | ||||
|                 let responseData = response.data | ||||
|                  | ||||
|                 do { | ||||
|                     let jsonDecoder = JSONDecoder() | ||||
|                     let decoded = try jsonDecoder.decode(ApiResponse<GetAudioContentDetailResponse>.self, from: responseData) | ||||
|                      | ||||
|                     if let data = decoded.data, decoded.success { | ||||
|                         self.audioContent = data | ||||
|                     } else { | ||||
|                         if let message = decoded.message { | ||||
|                             self.errorMessage = message | ||||
|                         } else { | ||||
|                             self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         } | ||||
|                          | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                 } catch { | ||||
|                     print(error) | ||||
|                     self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                     self.isShowPopup = true | ||||
|                 } | ||||
|                  | ||||
|                 self.isLoading = false | ||||
|             } | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
|      | ||||
|     func creatorFollow(userId: Int) { | ||||
|         isLoading = true | ||||
|          | ||||
|         userRepository.creatorFollow(creatorId: userId) | ||||
|             .sink { result in | ||||
|                 switch result { | ||||
|                 case .finished: | ||||
|                     DEBUG_LOG("finish") | ||||
|                 case .failure(let error): | ||||
|                     ERROR_LOG(error.localizedDescription) | ||||
|                 } | ||||
|             } receiveValue: { [unowned self] response in | ||||
|                 self.isLoading = false | ||||
|                 let responseData = response.data | ||||
|                  | ||||
|                 do { | ||||
|                     let jsonDecoder = JSONDecoder() | ||||
|                     let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) | ||||
|                      | ||||
|                     if decoded.success { | ||||
|                         self.getAudioContentDetail() | ||||
|                     } else { | ||||
|                         if let message = decoded.message { | ||||
|                             self.errorMessage = message | ||||
|                         } else { | ||||
|                             self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         } | ||||
|                          | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                 } catch { | ||||
|                     self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                     self.isShowPopup = true | ||||
|                 } | ||||
|             } | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
|      | ||||
|     func creatorUnFollow(userId: Int) { | ||||
|         isLoading = true | ||||
|          | ||||
|         userRepository.creatorUnFollow(creatorId: userId) | ||||
|             .sink { result in | ||||
|                 switch result { | ||||
|                 case .finished: | ||||
|                     DEBUG_LOG("finish") | ||||
|                 case .failure(let error): | ||||
|                     ERROR_LOG(error.localizedDescription) | ||||
|                 } | ||||
|             } receiveValue: { [unowned self] response in | ||||
|                 self.isLoading = false | ||||
|                 let responseData = response.data | ||||
|                  | ||||
|                 do { | ||||
|                     let jsonDecoder = JSONDecoder() | ||||
|                     let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) | ||||
|                      | ||||
|                     if decoded.success { | ||||
|                         self.getAudioContentDetail() | ||||
|                     } 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 likeContent() { | ||||
|         isLoading = true | ||||
|          | ||||
|         repository.likeContent(audioContentId: contentId) | ||||
|             .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<PutAudioContentLikeResponse>.self, from: responseData) | ||||
|                      | ||||
|                     if decoded.success { | ||||
|                         self.getAudioContentDetail() | ||||
|                     } 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 shareAudioContent(contentImage: String, contentTitle: String) { | ||||
|         isLoading = true | ||||
|         guard let link = URL(string: "https://yozm.day/?audio_content_id=\(contentId)") else { return } | ||||
|         let dynamicLinksDomainURIPrefix = "https://yozm.page.link" | ||||
|         guard let linkBuilder = DynamicLinkComponents(link: link, domainURIPrefix: dynamicLinksDomainURIPrefix) else { | ||||
|             self.errorMessage = "공유링크를 생성하지 못했습니다.\n다시 시도해 주세요." | ||||
|             self.isShowPopup = true | ||||
|             return | ||||
|         } | ||||
|          | ||||
|         linkBuilder.iOSParameters = DynamicLinkIOSParameters(bundleID: "kr.co.vividnext.yozm") | ||||
|         linkBuilder.iOSParameters?.appStoreID = "1630284226" | ||||
|          | ||||
|         linkBuilder.androidParameters = DynamicLinkAndroidParameters(packageName: "kr.co.vividnext.yozm") | ||||
|          | ||||
|         let socialMetaTagParameters = DynamicLinkSocialMetaTagParameters() | ||||
|         socialMetaTagParameters.title = contentTitle | ||||
|         socialMetaTagParameters.descriptionText = "지금 요즘라이브에서 이 콘텐츠 감상하기" | ||||
|         socialMetaTagParameters.imageURL = URL(string: contentImage) | ||||
|         linkBuilder.socialMetaTagParameters = socialMetaTagParameters | ||||
|          | ||||
|         guard let longDynamicLink = linkBuilder.url else { | ||||
|             self.errorMessage = "공유링크를 생성하지 못했습니다.\n다시 시도해 주세요." | ||||
|             self.isShowPopup = true | ||||
|             return | ||||
|         } | ||||
|         DEBUG_LOG("The long URL is: \(longDynamicLink)") | ||||
|          | ||||
|         DynamicLinkComponents.shortenURL(longDynamicLink, options: nil) { [unowned self] url, warnings, error in | ||||
|             let shortUrl = url?.absoluteString | ||||
|             let urlString = shortUrl != nil ? shortUrl! : longDynamicLink.absoluteString | ||||
|              | ||||
|             self.isLoading = false | ||||
|             self.shareMessage = urlString | ||||
|             self.isShowShareView = true | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func registerComment(comment: String) { | ||||
|         if comment.trimmingCharacters(in: .whitespaces).isEmpty { | ||||
|             return | ||||
|         } | ||||
|          | ||||
|         isLoading = true | ||||
|          | ||||
|         repository.registerComment(audioContentId: contentId, comment: comment) | ||||
|             .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.getAudioContentDetail() | ||||
|                     } 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 order(orderType: OrderType) { | ||||
|         isShowPreviewAlert = false | ||||
|         isLoading = true | ||||
|          | ||||
|         repository.orderAudioContent(audioContentId: contentId, orderType: orderType) | ||||
|             .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.orderType = nil | ||||
|                         self.errorMessage = "구매가 완료되었습니다." | ||||
|                         self.isShowPopup = true | ||||
|                         self.getAudioContentDetail() | ||||
|                         ContentPlayManager.shared.conditionalStopAudio(contentId: contentId) | ||||
|                     } else { | ||||
|                         if let message = decoded.message { | ||||
|                             self.errorMessage = message | ||||
|                         } else { | ||||
|                             self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         } | ||||
|                          | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                 } catch { | ||||
|                     self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                     self.isShowPopup = true | ||||
|                 } | ||||
|             } | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
|      | ||||
|     func report(type: ReportType, audioContentId: Int? = nil, reason: String = "프로필 신고") { | ||||
|         isLoading = true | ||||
|          | ||||
|         let request = ReportRequest(type: type, reason: reason, reportedMemberId: nil, cheersId: nil, audioContentId: audioContentId) | ||||
|         reportRepository.report(request: request) | ||||
|             .sink { result in | ||||
|                 switch result { | ||||
|                 case .finished: | ||||
|                     DEBUG_LOG("finish") | ||||
|                 case .failure(let error): | ||||
|                     ERROR_LOG(error.localizedDescription) | ||||
|                 } | ||||
|             } receiveValue: { [unowned self] response in | ||||
|                 self.isLoading = false | ||||
|                 let responseData = response.data | ||||
|                  | ||||
|                 do { | ||||
|                     let jsonDecoder = JSONDecoder() | ||||
|                     let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) | ||||
|                      | ||||
|                     if let message = decoded.message { | ||||
|                         self.errorMessage = message | ||||
|                     } else { | ||||
|                         self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                     } | ||||
|                      | ||||
|                     self.isShowPopup = true | ||||
|                 } catch { | ||||
|                     self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                     self.isShowPopup = true | ||||
|                 } | ||||
|             } | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
|      | ||||
|     func deleteAudioContent(onSuccess: @escaping () -> Void) { | ||||
|         isLoading = true | ||||
|          | ||||
|         repository.deleteAudioContent(audioContentId: contentId) | ||||
|             .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.orderType = nil | ||||
|                         self.errorMessage = "삭제되었습니다" | ||||
|                         self.isShowPopup = true | ||||
|                         onSuccess() | ||||
|                     } 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 donation(can: Int, comment: String) { | ||||
|         if can <= 0 { | ||||
|             self.errorMessage = "1코인 이상 후원하실 수 있습니다." | ||||
|             self.isShowPopup = true | ||||
|         } else if comment.trimmingCharacters(in: .whitespaces).isEmpty { | ||||
|             self.errorMessage = "함께 보낼 메시지를 입력하세요." | ||||
|             self.isShowPopup = true | ||||
|         } else { | ||||
|             isLoading = true | ||||
|             repository.donation(contentId: contentId, can: can, comment: comment) | ||||
|                 .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 { | ||||
|                             UserDefaults.set(UserDefaults.int(forKey: .can) - can, forKey: .can) | ||||
|                             self.errorMessage = "\(can)코인을 후원하셨습니다." | ||||
|                             self.isShowPopup = true | ||||
|                              | ||||
|                             self.getAudioContentDetail() | ||||
|                         } 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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,155 @@ | ||||
| // | ||||
| //  ContentOrderConfirmDialogView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct ContentOrderConfirmDialogView: View { | ||||
|      | ||||
|     @Binding var isShowing: Bool | ||||
|      | ||||
|     let audioContent: GetAudioContentDetailResponse | ||||
|     let orderType: OrderType | ||||
|     let onClickConfirm: () -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             Color | ||||
|                 .black | ||||
|                 .opacity(0.7) | ||||
|                 .ignoresSafeArea() | ||||
|              | ||||
|             VStack(spacing: 0) { | ||||
|                 Text("구매확인") | ||||
|                     .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                  | ||||
|                 HStack(spacing: 11) { | ||||
|                     ZStack(alignment: .topLeading) { | ||||
|                         KFImage(URL(string: audioContent.coverImageUrl)) | ||||
|                             .resizable() | ||||
|                             .frame(width: 88.7, height: 88.7, alignment: .center) | ||||
|                             .clipped() | ||||
|                             .cornerRadius(4) | ||||
|                          | ||||
|                         if audioContent.isAdult { | ||||
|                             Text("19") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 11.3)) | ||||
|                                 .foregroundColor(Color.white) | ||||
|                                 .padding(4) | ||||
|                                 .background(Color(hex: "e53621")) | ||||
|                                 .clipShape(Circle()) | ||||
|                                 .padding(.leading, 4.3) | ||||
|                                 .padding(.top, 4.3) | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|                     VStack(alignment: .leading, spacing: 0) { | ||||
|                         Text(audioContent.themeStr) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 8)) | ||||
|                             .foregroundColor(Color(hex: "3bac6a")) | ||||
|                             .padding(2.3) | ||||
|                             .background(Color(hex: "28312b")) | ||||
|                             .cornerRadius(2) | ||||
|                          | ||||
|                         Text(audioContent.title) | ||||
|                             .font(.custom(Font.bold.rawValue, size: 11.3)) | ||||
|                             .foregroundColor(Color(hex: "d2d2d2")) | ||||
|                             .padding(.top, 2) | ||||
|                          | ||||
|                         HStack(spacing: 4.3) { | ||||
|                             KFImage(URL(string: audioContent.creator.profileImageUrl)) | ||||
|                                 .cancelOnDisappear(true) | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 13.3, height: 13.3) | ||||
|                                 .clipShape(Circle()) | ||||
|                              | ||||
|                             Text(audioContent.creator.nickname) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                                 .foregroundColor(Color(hex: "777777")) | ||||
|                         } | ||||
|                         .padding(.top, 6.7) | ||||
|                          | ||||
|                         Text(audioContent.duration) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 11)) | ||||
|                             .foregroundColor(Color(hex: "777777")) | ||||
|                             .padding(.top, 6.7) | ||||
|                     } | ||||
|                      | ||||
|                     Spacer() | ||||
|                 } | ||||
|                 .padding(8) | ||||
|                 .background(Color.black) | ||||
|                 .cornerRadius(5.3) | ||||
|                 .padding(.top, 21.3) | ||||
|                  | ||||
|                 Text("콘텐츠를 \(orderType == .RENTAL ? "대여" : "소장")하시겠습니까?\n아래 코인이 차감됩니다.") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                     .fixedSize(horizontal: false, vertical: true) | ||||
|                     .multilineTextAlignment(.center) | ||||
|                     .padding(.top, 13.3) | ||||
|                  | ||||
|                 HStack(spacing: 2.7) { | ||||
|                     Spacer() | ||||
|                      | ||||
|                     Image("ic_can") | ||||
|                         .resizable() | ||||
|                         .frame(width: 16.7, height: 16.7) | ||||
|                      | ||||
|                     Text("\(orderType == .RENTAL ? Int(ceil(Double(audioContent.price) * 0.7)) : audioContent.price)") | ||||
|                         .font(.custom(Font.bold.rawValue, size: 13.3)) | ||||
|                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                      | ||||
|                     Spacer() | ||||
|                 } | ||||
|                 .padding(.vertical, 13.3) | ||||
|                 .background(Color(hex: "333333")) | ||||
|                 .cornerRadius(6.7) | ||||
|                 .overlay( | ||||
|                     RoundedRectangle(cornerRadius: CGFloat(6.7)) | ||||
|                         .stroke(lineWidth: 1) | ||||
|                         .foregroundColor(Color(hex: "979797")) | ||||
|                 ) | ||||
|                 .padding(.top, 13.3) | ||||
|                  | ||||
|                 HStack(spacing: 12) { | ||||
|                     Text("취소") | ||||
|                         .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                         .foregroundColor(Color(hex: "9970ff")) | ||||
|                         .padding(.vertical, 15.7) | ||||
|                         .frame(maxWidth: .infinity) | ||||
|                         .overlay( | ||||
|                             RoundedRectangle(cornerRadius: CGFloat(10)) | ||||
|                                 .stroke(lineWidth: 1) | ||||
|                                 .foregroundColor(Color(hex: "9970ff")) | ||||
|                         ) | ||||
|                         .onTapGesture { isShowing = false } | ||||
|                      | ||||
|                     Text("확인") | ||||
|                         .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                         .foregroundColor(.white) | ||||
|                         .padding(.vertical, 15.7) | ||||
|                         .frame(maxWidth: .infinity) | ||||
|                         .background(Color(hex: "9970ff")) | ||||
|                         .cornerRadius(10) | ||||
|                         .onTapGesture { | ||||
|                             onClickConfirm() | ||||
|                             isShowing = false | ||||
|                         } | ||||
|                 } | ||||
|                 .padding(.top, 21.3) | ||||
|             } | ||||
|             .padding(.horizontal, 13.3) | ||||
|             .padding(.top, 26.7) | ||||
|             .padding(.bottom, 16.7) | ||||
|             .background(Color(hex: "222222")) | ||||
|             .cornerRadius(10) | ||||
|             .padding(.horizontal, 20) | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										97
									
								
								SodaLive/Sources/Content/Detail/ContentOrderDialogView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,97 @@ | ||||
| // | ||||
| //  ContentOrderDialogView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct ContentOrderDialogView: View { | ||||
|      | ||||
|     @Binding var isShowing: Bool | ||||
|      | ||||
|     let price: Int | ||||
|     let onTapPurchase: (OrderType) -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             Color.black | ||||
|                 .opacity(0.7) | ||||
|                 .ignoresSafeArea() | ||||
|                 .onTapGesture { isShowing = false } | ||||
|              | ||||
|             VStack(spacing: 0) { | ||||
|                 Spacer() | ||||
|                  | ||||
|                 VStack(spacing: 26.7) { | ||||
|                     HStack(spacing: 0) { | ||||
|                         VStack(alignment: .leading, spacing: 5.3) { | ||||
|                             Text("대여") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 13.3)) | ||||
|                                 .foregroundColor(.white) | ||||
|                              | ||||
|                             Text("(이용기간 7일)") | ||||
|                                 .font(.custom(Font.light.rawValue, size: 12)) | ||||
|                                 .foregroundColor(.white) | ||||
|                         } | ||||
|                          | ||||
|                         Spacer() | ||||
|                          | ||||
|                         HStack(spacing: 8) { | ||||
|                             Image("ic_can") | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 16.7, height: 16.7) | ||||
|                              | ||||
|                             Text("\(Int(ceil(Double(price) * 0.7)))") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 13.3)) | ||||
|                                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                         } | ||||
|                         .padding(.vertical, 8) | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                         .background(Color(hex: "9970ff")) | ||||
|                         .cornerRadius(5.3) | ||||
|                         .onTapGesture { | ||||
|                             onTapPurchase(.RENTAL) | ||||
|                             isShowing = false | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|                     HStack(spacing: 0) { | ||||
|                         VStack(alignment: .leading, spacing: 5.3) { | ||||
|                             Text("소장") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 13.3)) | ||||
|                                 .foregroundColor(.white) | ||||
|                              | ||||
|                             Text("(서비스 종료시까지)") | ||||
|                                 .font(.custom(Font.light.rawValue, size: 12)) | ||||
|                                 .foregroundColor(.white) | ||||
|                         } | ||||
|                          | ||||
|                         Spacer() | ||||
|                          | ||||
|                         HStack(spacing: 8) { | ||||
|                             Image("ic_coin_w") | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 16.7, height: 16.7) | ||||
|                              | ||||
|                             Text("\(price)") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 13.3)) | ||||
|                                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                         } | ||||
|                         .padding(.vertical, 8) | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                         .background(Color(hex: "9970ff")) | ||||
|                         .cornerRadius(5.3) | ||||
|                         .onTapGesture { | ||||
|                             onTapPurchase(.KEEP) | ||||
|                             isShowing = false | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 .padding(24) | ||||
|                 .background(Color(hex: "222222")) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										257
									
								
								SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,257 @@ | ||||
| // | ||||
| //  LiveRoomDonationDialogView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Combine | ||||
|  | ||||
| import Kingfisher | ||||
|  | ||||
| struct LiveRoomDonationDialogView: View { | ||||
|      | ||||
|     @State private var donationCan = "" | ||||
|     @State private var donationMessage = "" | ||||
|     @State private var isShowErrorPopup = false | ||||
|     @State private var errorMessage = "" | ||||
|     @State private var can = 0 | ||||
|      | ||||
|     @Binding var isShowing: Bool | ||||
|     let isAudioContentDonation: Bool | ||||
|     let onClickDonation: (Int, String) -> Void | ||||
|      | ||||
|     @StateObject var keyboardHandler = KeyboardHandler() | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             Color.black | ||||
|                 .opacity(0.7) | ||||
|                 .ignoresSafeArea() | ||||
|                 .onTapGesture { | ||||
|                     hideKeyboard() | ||||
|                 } | ||||
|              | ||||
|             VStack(spacing: 0) { | ||||
|                 Spacer() | ||||
|                 VStack(spacing: 0) { | ||||
|                     HStack(spacing: 5.3) { | ||||
|                         Image("ic_donation_white") | ||||
|                             .resizable() | ||||
|                             .frame(width: 26.7, height: 26.7) | ||||
|                          | ||||
|                         Text("후원하기") | ||||
|                             .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                             .foregroundColor(Color(hex: "eeeeee")) | ||||
|                          | ||||
|                         Spacer() | ||||
|                          | ||||
|                         HStack(spacing: 5.3) { | ||||
|                             Image("ic_can") | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 26.7, height: 26.7) | ||||
|                              | ||||
|                             Text("\(can)") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                              | ||||
|                             Image("ic_forward") | ||||
|                         } | ||||
|                         .onTapGesture { | ||||
|                             AppState.shared.setAppStep(step: .canCharge(refresh: {})) | ||||
|                             self.isShowing = false | ||||
|                         } | ||||
|                     } | ||||
|                     .padding(.leading, 23.3) | ||||
|                     .padding(.trailing, 26.7) | ||||
|                      | ||||
|                     Rectangle() | ||||
|                         .frame(height: 1) | ||||
|                         .foregroundColor(Color(hex: "909090")) | ||||
|                         .padding(.top, 16) | ||||
|                      | ||||
|                     TextField("몇 캔을 후원할까요?", text: $donationCan) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                         .padding(13.3) | ||||
|                         .keyboardType(.numberPad) | ||||
|                         .background(Color(hex: "303030")) | ||||
|                         .cornerRadius(6.7) | ||||
|                         .padding(.horizontal, 20) | ||||
|                         .padding(.top, 16) | ||||
|                      | ||||
|                     HStack(spacing: 0) { | ||||
|                         Text("+10") | ||||
|                             .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(.white) | ||||
|                             .padding(.vertical, 12.7) | ||||
|                             .frame(width: 74) | ||||
|                             .background(Color(hex: "9970ff")) | ||||
|                             .cornerRadius(6.7) | ||||
|                             .onTapGesture { | ||||
|                                 if !donationCan.trimmingCharacters(in: .whitespaces).isEmpty, | ||||
|                                    let can = Int(donationCan) { | ||||
|                                     donationCan = "\(can + 10)" | ||||
|                                 } else { | ||||
|                                     donationCan = "\(10)" | ||||
|                                 } | ||||
|                             } | ||||
|                          | ||||
|                         Spacer() | ||||
|                          | ||||
|                         Text("+100") | ||||
|                             .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(.white) | ||||
|                             .padding(.vertical, 12.7) | ||||
|                             .frame(width: 74) | ||||
|                             .background(Color(hex: "9970ff")) | ||||
|                             .cornerRadius(6.7) | ||||
|                             .onTapGesture { | ||||
|                                 if !donationCan.trimmingCharacters(in: .whitespaces).isEmpty, | ||||
|                                    let coin = Int(donationCan) { | ||||
|                                     donationCan = "\(coin + 100)" | ||||
|                                 } else { | ||||
|                                     donationCan = "\(100)" | ||||
|                                 } | ||||
|                             } | ||||
|                         Spacer() | ||||
|                          | ||||
|                         Text("+1,000") | ||||
|                             .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(.white) | ||||
|                             .padding(.vertical, 12.7) | ||||
|                             .frame(width: 74) | ||||
|                             .background(Color(hex: "9970ff")) | ||||
|                             .cornerRadius(6.7) | ||||
|                             .onTapGesture { | ||||
|                                 if !donationCan.trimmingCharacters(in: .whitespaces).isEmpty, | ||||
|                                    let can = Int(donationCan) { | ||||
|                                     donationCan = "\(can + 1000)" | ||||
|                                 } else { | ||||
|                                     donationCan = "\(1000)" | ||||
|                                 } | ||||
|                             } | ||||
|                          | ||||
|                         Spacer() | ||||
|                          | ||||
|                         Text("+10,000") | ||||
|                             .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(.white) | ||||
|                             .padding(.vertical, 12.7) | ||||
|                             .frame(width: 74) | ||||
|                             .background(Color(hex: "9970ff")) | ||||
|                             .cornerRadius(6.7) | ||||
|                             .onTapGesture { | ||||
|                                 if !donationCan.trimmingCharacters(in: .whitespaces).isEmpty, | ||||
|                                    let can = Int(donationCan) { | ||||
|                                     donationCan = "\(can + 10000)" | ||||
|                                 } else { | ||||
|                                     donationCan = "\(10000)" | ||||
|                                 } | ||||
|                             } | ||||
|                     } | ||||
|                     .padding(.top, 26) | ||||
|                     .padding(.horizontal, 20) | ||||
|                      | ||||
|                     Rectangle() | ||||
|                         .frame(height: 1) | ||||
|                         .foregroundColor(Color(hex: "909090")) | ||||
|                         .padding(.vertical, 18.7) | ||||
|                         .padding(.horizontal, 20) | ||||
|                      | ||||
|                     HStack(spacing: 10.7) { | ||||
|                         KFImage(URL(string: UserDefaults.string(forKey: .profileImage))) | ||||
|                             .cancelOnDisappear(true) | ||||
|                             .downsampling(size: CGSize(width: 40, height: 40)) | ||||
|                             .resizable() | ||||
|                             .frame(width: 40, height: 40) | ||||
|                             .clipShape(Circle()) | ||||
|                             .overlay( | ||||
|                                 Circle() | ||||
|                                     .stroke(Color(hex: "bbbbbb"), lineWidth: 1) | ||||
|                             ) | ||||
|                          | ||||
|                         TextField("함께 보낼 메시지 입력(최대 50자)", text: $donationMessage) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                             .foregroundColor(Color(hex: "eeeeee")) | ||||
|                             .padding(13.3) | ||||
|                             .background(Color(hex: "303030")) | ||||
|                             .cornerRadius(6.7) | ||||
|                             .onReceive(Just(donationMessage)) { _ in | ||||
|                                 limitText() | ||||
|                             } | ||||
|                     } | ||||
|                     .padding(.horizontal, 20) | ||||
|                      | ||||
|                     HStack(spacing: 13.3) { | ||||
|                         Text("취소") | ||||
|                             .font(.custom(Font.bold.rawValue, size: 15)) | ||||
|                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                             .padding(.vertical, 16) | ||||
|                             .frame(width: (screenSize().width - 53.3) / 3) | ||||
|                             .background(Color(hex: "9970ff").opacity(0.2)) | ||||
|                             .cornerRadius(10) | ||||
|                             .overlay( | ||||
|                                 RoundedRectangle(cornerRadius: 10) | ||||
|                                     .strokeBorder() | ||||
|                                     .foregroundColor(Color(hex: "9970ff")) | ||||
|                             ) | ||||
|                             .onTapGesture { | ||||
|                                 isShowing = false | ||||
|                             } | ||||
|                          | ||||
|                         Text("후원하기") | ||||
|                             .font(.custom(Font.bold.rawValue, size: 15)) | ||||
|                             .foregroundColor(.white) | ||||
|                             .padding(.vertical, 16) | ||||
|                             .frame(width: (screenSize().width - 53.3) * 2 / 3) | ||||
|                             .background(Color(hex: "9970ff")) | ||||
|                             .cornerRadius(10) | ||||
|                             .onTapGesture { | ||||
|                                 if !donationCan.trimmingCharacters(in: .whitespaces).isEmpty, | ||||
|                                    let can = Int(donationCan) { | ||||
|                                     onClickDonation(can, donationMessage) | ||||
|                                     isShowing = false | ||||
|                                 } else { | ||||
|                                     errorMessage = "1캔 이상 후원하실 수 있습니다." | ||||
|                                     isShowErrorPopup = true | ||||
|                                 } | ||||
|                             } | ||||
|                     } | ||||
|                     .padding(.horizontal, 16.7) | ||||
|                     .padding(.top, 18.7) | ||||
|                 } | ||||
|                 .padding(.top, 21.3) | ||||
|                 .padding(.bottom, 16) | ||||
|                 .background(Color(hex: "222222")) | ||||
|                 .cornerRadius(20, corners: [.topLeft, .topRight]) | ||||
|             } | ||||
|             .popup(isPresented: $isShowErrorPopup, type: .toast, position: .bottom, autohideIn: 1.3) { | ||||
|                 HStack { | ||||
|                     Spacer() | ||||
|                     Text(errorMessage) | ||||
|                         .padding(.vertical, 13.3) | ||||
|                         .frame(width: screenSize().width - 66.7, alignment: .center) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                         .background(Color(hex: "9970ff")) | ||||
|                         .foregroundColor(Color.white) | ||||
|                         .multilineTextAlignment(.leading) | ||||
|                         .cornerRadius(20) | ||||
|                         .padding(.bottom, 66.7) | ||||
|                     Spacer() | ||||
|                 } | ||||
|             } | ||||
|             .offset(y: isAudioContentDonation ? 0 : 0 - keyboardHandler.keyboardHeight) | ||||
|         } | ||||
|         .onAppear { | ||||
|             self.can = UserDefaults.int(forKey: .can) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func limitText() { | ||||
|         if donationMessage.count > 50 { | ||||
|             donationMessage = String(donationMessage.prefix(50)) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -6,7 +6,7 @@ | ||||
| // | ||||
|  | ||||
| struct PutAudioContentLikeRequest: Encodable { | ||||
|     let audioContentId: Int | ||||
|     let contentId: Int | ||||
| } | ||||
|  | ||||
| struct PutAudioContentLikeResponse: Decodable { | ||||
|   | ||||
| @@ -9,7 +9,7 @@ import Foundation | ||||
|  | ||||
| struct AudioContentDonationRequest: Encodable { | ||||
|     let audioContentId: Int | ||||
|     let donationCoin: Int | ||||
|     let donationCan: Int | ||||
|     let comment: String | ||||
|     let container: String = "ios" | ||||
| } | ||||
|   | ||||
| @@ -57,7 +57,7 @@ struct ContentMainItemView: View { | ||||
|             .padding(.bottom, 10) | ||||
|         } | ||||
|         .frame(width: 133.3, alignment: .leading) | ||||
|         .onTapGesture { } | ||||
|         .onTapGesture { AppState.shared.setAppStep(step: .contentDetail(contentId: item.contentId)) } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										260
									
								
								SodaLive/Sources/Content/Modify/ContentModifyView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,260 @@ | ||||
| // | ||||
| //  ContentModifyView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct ContentModifyView: View { | ||||
|      | ||||
|     let contentId: Int | ||||
|     @StateObject var keyboardHandler = KeyboardHandler() | ||||
|     @StateObject private var viewModel = ContentModifyViewModel() | ||||
|      | ||||
|     @State private var isShowPhotoPicker = false | ||||
|      | ||||
|     var body: some View { | ||||
|         BaseView(isLoading: $viewModel.isLoading) { | ||||
|             GeometryReader { proxy in | ||||
|                 ZStack { | ||||
|                     VStack(spacing: 0) { | ||||
|                         DetailNavigationBar(title: "콘텐츠 수정") | ||||
|                          | ||||
|                         ScrollView(.vertical, showsIndicators: false) { | ||||
|                             VStack(spacing: 0) { | ||||
|                                 Text("썸네일") | ||||
|                                     .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                                     .frame(maxWidth: .infinity, alignment: .leading) | ||||
|                                  | ||||
|                                 ZStack { | ||||
|                                     if let selectedImage = viewModel.coverImage { | ||||
|                                         Image(uiImage: selectedImage) | ||||
|                                             .resizable() | ||||
|                                             .scaledToFill() | ||||
|                                             .frame(width: 107, height: 107) | ||||
|                                             .background(Color(hex: "3e3358")) | ||||
|                                             .cornerRadius(8) | ||||
|                                             .clipped() | ||||
|                                     } else if let coverImageUrl = viewModel.coverImageUrl { | ||||
|                                         KFImage(URL(string: coverImageUrl)) | ||||
|                                             .resizable() | ||||
|                                             .scaledToFill() | ||||
|                                             .frame(width: 107, height: 107) | ||||
|                                             .background(Color(hex: "3e3358")) | ||||
|                                             .cornerRadius(8) | ||||
|                                             .clipped() | ||||
|                                     } else { | ||||
|                                         Image("ic_logo") | ||||
|                                             .resizable() | ||||
|                                             .scaledToFit() | ||||
|                                             .padding(13.3) | ||||
|                                             .frame(width: 107, height: 107) | ||||
|                                             .background(Color(hex: "3e3358")) | ||||
|                                             .cornerRadius(8) | ||||
|                                     } | ||||
|                                      | ||||
|                                     Image("ic_camera") | ||||
|                                         .padding(10) | ||||
|                                         .background(Color(hex: "9970ff")) | ||||
|                                         .cornerRadius(30) | ||||
|                                         .offset(x: 50, y: 36) | ||||
|                                 } | ||||
|                                 .frame(alignment: .bottomTrailing) | ||||
|                                 .onTapGesture { isShowPhotoPicker = true } | ||||
|                             } | ||||
|                             .padding(.top, 13.3) | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                              | ||||
|                             Rectangle() | ||||
|                                 .foregroundColor(Color(hex: "232323")) | ||||
|                                 .frame(height: 6.7) | ||||
|                                 .padding(.top, 26.7) | ||||
|                              | ||||
|                             VStack(spacing: 0) { | ||||
|                                 Text("제목") | ||||
|                                     .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                                     .frame(maxWidth: .infinity, alignment: .leading) | ||||
|                                  | ||||
|                                 TextField("제목을 입력하세요", text: $viewModel.title) | ||||
|                                     .autocapitalization(.none) | ||||
|                                     .disableAutocorrection(true) | ||||
|                                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                                     .padding(.vertical, 16.7) | ||||
|                                     .padding(.horizontal, 13.3) | ||||
|                                     .background(Color(hex: "222222")) | ||||
|                                     .cornerRadius(6.7) | ||||
|                                     .keyboardType(.default) | ||||
|                                     .padding(.top, 13.3) | ||||
|                                  | ||||
|                                 HStack(spacing: 0) { | ||||
|                                     Text("내용") | ||||
|                                         .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                                      | ||||
|                                     Spacer() | ||||
|                                      | ||||
|                                     Text("\(viewModel.detail.count)자") | ||||
|                                         .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                                         .foregroundColor(Color(hex: "ff5c49")) + | ||||
|                                     Text(" / 최대 500자") | ||||
|                                         .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                                         .foregroundColor(Color(hex: "777777")) | ||||
|                                 } | ||||
|                                 .padding(.top, 26.7) | ||||
|                                  | ||||
|                                 TextViewWrapper( | ||||
|                                     text: $viewModel.detail, | ||||
|                                     placeholder: viewModel.placeholder, | ||||
|                                     textColorHex: "eeeeee", | ||||
|                                     backgroundColorHex: "222222" | ||||
|                                 ) | ||||
|                                 .frame(height: 184) | ||||
|                                 .cornerRadius(6.7) | ||||
|                                 .padding(.top, 13.3) | ||||
|                             } | ||||
|                             .padding(.top, 26.7) | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                              | ||||
|                             Rectangle() | ||||
|                                 .foregroundColor(Color(hex: "232323")) | ||||
|                                 .frame(height: 6.7) | ||||
|                                 .padding(.top, 26.7) | ||||
|                              | ||||
|                             if viewModel.isAdultShowUi { | ||||
|                                 VStack(spacing: 13.3) { | ||||
|                                     Text("연령 제한") | ||||
|                                         .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                                         .frame(maxWidth: .infinity, alignment: .leading) | ||||
|                                      | ||||
|                                     HStack(spacing: 13.3) { | ||||
|                                         SelectButtonView(title: "전체 연령", isChecked: !viewModel.isAdult) { | ||||
|                                             if viewModel.isAdult { | ||||
|                                                 viewModel.isAdult = false | ||||
|                                             } | ||||
|                                         } | ||||
|                                          | ||||
|                                         SelectButtonView(title: "19세 이상", isChecked: viewModel.isAdult) { | ||||
|                                             if !viewModel.isAdult { | ||||
|                                                 viewModel.isAdult = true | ||||
|                                             } | ||||
|                                         } | ||||
|                                     } | ||||
|                                      | ||||
|                                     Text("성인콘텐츠를 전체관람가로 등록할 시 발생하는 법적 책임은 회사와 상관없이 콘텐츠를 등록한 본인에게 있습니다.\n콘텐츠 내용은 물론 제목도 19금 여부를 체크해 주시기 바랍니다.") | ||||
|                                         .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                                         .foregroundColor(Color(hex: "DD4500")) | ||||
|                                         .frame(maxWidth: .infinity, alignment: .leading) | ||||
|                                         .padding(.top, 13.3) | ||||
|                                 } | ||||
|                                 .padding(.top, 26.7) | ||||
|                                 .padding(.horizontal, 13.3) | ||||
|                             } | ||||
|                              | ||||
|                             VStack(spacing: 13.3) { | ||||
|                                 Text("댓글 가능 여부") | ||||
|                                     .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                                     .frame(maxWidth: .infinity, alignment: .leading) | ||||
|                                  | ||||
|                                 HStack(spacing: 13.3) { | ||||
|                                     SelectButtonView(title: "댓글 가능", isChecked: viewModel.isAvailableComment) { | ||||
|                                         if !viewModel.isAvailableComment { | ||||
|                                             viewModel.isAvailableComment = true | ||||
|                                         } | ||||
|                                     } | ||||
|                                      | ||||
|                                     SelectButtonView(title: "댓글 불가", isChecked: !viewModel.isAvailableComment) { | ||||
|                                         if viewModel.isAvailableComment { | ||||
|                                             viewModel.isAvailableComment = false | ||||
|                                         } | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                             .padding(.top, 26.7) | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                              | ||||
|                             VStack(spacing: 0) { | ||||
|                                 HStack(alignment: .top, spacing: 0) { | ||||
|                                     Text("등록") | ||||
|                                         .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                                         .foregroundColor(Color.white) | ||||
|                                         .frame(height: 50) | ||||
|                                         .frame(maxWidth: .infinity) | ||||
|                                         .background(Color(hex: "9970ff")) | ||||
|                                         .cornerRadius(10) | ||||
|                                         .padding(13.3) | ||||
|                                 } | ||||
|                                 .frame(maxWidth: .infinity) | ||||
|                                 .background(Color(hex: "222222")) | ||||
|                                 .cornerRadius(16.7, corners: [.topLeft, .topRight]) | ||||
|                                 .onTapGesture { | ||||
|                                     hideKeyboard() | ||||
|                                     viewModel.modifyAudioContent { | ||||
|                                         DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { | ||||
|                                             AppState.shared.back() | ||||
|                                         } | ||||
|                                     } | ||||
|                                 } | ||||
|                                  | ||||
|                                 Rectangle() | ||||
|                                     .foregroundColor(Color(hex: "222222")) | ||||
|                                     .frame(height: keyboardHandler.keyboardHeight) | ||||
|                                     .frame(maxWidth: .infinity) | ||||
|                                  | ||||
|                                 if proxy.safeAreaInsets.bottom > 0 { | ||||
|                                     Rectangle() | ||||
|                                         .foregroundColor(Color(hex: "222222")) | ||||
|                                         .frame(height: 15.3) | ||||
|                                         .frame(maxWidth: .infinity) | ||||
|                                 } | ||||
|                             } | ||||
|                             .padding(.top, 30) | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|                     if isShowPhotoPicker { | ||||
|                         ImagePicker( | ||||
|                             isShowing: $isShowPhotoPicker, | ||||
|                             selectedImage: $viewModel.coverImage, | ||||
|                             sourceType: .photoLibrary | ||||
|                         ) | ||||
|                     } | ||||
|                 } | ||||
|                 .onTapGesture { hideKeyboard() } | ||||
|                 .edgesIgnoringSafeArea(.bottom) | ||||
|                 .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { | ||||
|                     GeometryReader { geo in | ||||
|                         HStack { | ||||
|                             Spacer() | ||||
|                             Text(viewModel.errorMessage) | ||||
|                                 .padding(.vertical, 13.3) | ||||
|                                 .frame(width: screenSize().width - 66.7, alignment: .center) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                                 .background(Color(hex: "9970ff")) | ||||
|                                 .foregroundColor(Color.white) | ||||
|                                 .multilineTextAlignment(.center) | ||||
|                                 .cornerRadius(20) | ||||
|                                 .padding(.top, 66.7) | ||||
|                             Spacer() | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 .onAppear { | ||||
|                     viewModel.contentId = contentId | ||||
|                     viewModel.getAudioContentDetail { | ||||
|                         DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { | ||||
|                             AppState.shared.back() | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										177
									
								
								SodaLive/Sources/Content/Modify/ContentModifyViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,177 @@ | ||||
| // | ||||
| //  ContentModifyViewModel.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import UIKit | ||||
| import Moya | ||||
| import Combine | ||||
|  | ||||
| final class ContentModifyViewModel: ObservableObject { | ||||
|     private let repository = ContentRepository() | ||||
|     private var subscription = Set<AnyCancellable>() | ||||
|      | ||||
|     @Published var errorMessage = "" | ||||
|     @Published var isShowPopup = false | ||||
|     @Published var isLoading = false | ||||
|      | ||||
|     @Published private(set) var audioContent: GetAudioContentDetailResponse? | ||||
|      | ||||
|     @Published var title: String = "" | ||||
|     @Published var detail: String = "" | ||||
|     @Published var coverImage: UIImage? = nil | ||||
|     @Published var coverImageUrl: String? = nil | ||||
|      | ||||
|     @Published var isAvailableComment = true | ||||
|     @Published var isAdult = false | ||||
|     @Published var isAdultShowUi = false | ||||
|      | ||||
|     var contentId: Int = 0 | ||||
|     var placeholder = "내용을 입력하세요" | ||||
|      | ||||
|     func getAudioContentDetail(onFailure: (() -> Void)? = nil) { | ||||
|         audioContent = nil | ||||
|         isLoading = true | ||||
|          | ||||
|         repository.getAudioContentDetail(audioContentId: contentId) | ||||
|             .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<GetAudioContentDetailResponse>.self, from: responseData) | ||||
|                      | ||||
|                     if let data = decoded.data, decoded.success { | ||||
|                         self.audioContent = data | ||||
|                          | ||||
|                         self.title = data.title | ||||
|                         self.detail = data.detail | ||||
|                         self.isAdult = data.isAdult | ||||
|                         self.isAdultShowUi = !data.isAdult | ||||
|                         self.coverImageUrl = data.coverImageUrl | ||||
|                         self.isAvailableComment = data.isCommentAvailable | ||||
|                     } 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 modifyAudioContent(onSuccess: @escaping () -> Void) { | ||||
|         if !isLoading && contentId > 0 && validateData() { | ||||
|             isLoading = true | ||||
|              | ||||
|             let request = ModifyContentRequest( | ||||
|                 audioContentId: contentId, | ||||
|                 title: title != audioContent!.title ? title : nil, | ||||
|                 detail: detail != audioContent!.detail ? detail : nil, | ||||
|                 isAdult: isAdult, | ||||
|                 isCommentAvailable: isAvailableComment | ||||
|             ) | ||||
|              | ||||
|             var multipartData = [MultipartFormData]() | ||||
|              | ||||
|             let encoder = JSONEncoder() | ||||
|             encoder.outputFormatting = .withoutEscapingSlashes | ||||
|             let jsonData = try? encoder.encode(request) | ||||
|              | ||||
|             if let jsonData = jsonData { | ||||
|                 if let coverImage = coverImage { | ||||
|                     if let imageData = coverImage.jpegData(compressionQuality: 0.8) { | ||||
|                         multipartData.append( | ||||
|                             MultipartFormData( | ||||
|                                 provider: .data(imageData), | ||||
|                                 name: "coverImage", | ||||
|                                 fileName: "\(UUID().uuidString)_\(Date().timeIntervalSince1970 * 1000).jpg", | ||||
|                                 mimeType: "image/*") | ||||
|                         ) | ||||
|                     } else { | ||||
|                         errorMessage = "커버이미지를 업로드 하지 못했습니다.\n다시 선택해 주세요" | ||||
|                         isShowPopup = true | ||||
|                         isLoading = false | ||||
|                         return | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 multipartData.append(MultipartFormData(provider: .data(jsonData), name: "request")) | ||||
|                  | ||||
|                 repository | ||||
|                     .modifyAudioContent(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 { | ||||
|                                 self.errorMessage = "콘텐츠가 수정되었습니다." | ||||
|                                 self.isShowPopup = true | ||||
|                                 onSuccess() | ||||
|                             } 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) | ||||
|             } else { | ||||
|                 self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                 self.isShowPopup = true | ||||
|                 self.isLoading = false | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func validateData() -> Bool { | ||||
|         if title != audioContent!.title && title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | ||||
|             errorMessage = "제목을 입력해 주세요." | ||||
|             isShowPopup = true | ||||
|             return false | ||||
|         } | ||||
|          | ||||
|         if detail != audioContent!.detail && (detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || detail.count < 5) { | ||||
|             errorMessage = "내용을 5자 이상 입력해 주세요." | ||||
|             isShowPopup = true | ||||
|             return false | ||||
|         } | ||||
|          | ||||
|         return true | ||||
|     } | ||||
| } | ||||
							
								
								
									
										16
									
								
								SodaLive/Sources/Content/Modify/ModifyContentRequest.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,16 @@ | ||||
| // | ||||
| //  ModifyContentRequest.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/13. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct ModifyContentRequest: Encodable { | ||||
|     let audioContentId: Int | ||||
|     let title: String? | ||||
|     let detail: String? | ||||
|     let isAdult: Bool | ||||
|     let isCommentAvailable: Bool | ||||
| } | ||||
							
								
								
									
										44
									
								
								SodaLive/Sources/Content/PlaybackTracking.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,44 @@ | ||||
| // | ||||
| //  PlaybackTracking.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/11. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import ObjectBox | ||||
|  | ||||
| class PlaybackTracking: Entity { | ||||
|     var id: Id = 0 | ||||
|     var audioContentId: Int | ||||
|     var totalDuration: Int | ||||
|     var startPosition: Int | ||||
|     var isFree: Bool | ||||
|     var isPreview: Bool | ||||
|     var endPosition: Int? = nil | ||||
|     var playDateTime: String = Date().convertDateFormat(dateFormat: "yyyy-MM-dd HH:mm:ss") | ||||
|      | ||||
|     required init() { | ||||
|         audioContentId = 0 | ||||
|         totalDuration = 0 | ||||
|         startPosition = 0 | ||||
|         isFree = true | ||||
|         isPreview = true | ||||
|         endPosition = nil | ||||
|     } | ||||
|      | ||||
|     convenience init( | ||||
|         audioContentId: Int, | ||||
|         totalDuration: Int, | ||||
|         startPosition: Int, | ||||
|         isFree: Bool, | ||||
|         isPreview: Bool | ||||
|     ) { | ||||
|         self.init() | ||||
|         self.audioContentId = audioContentId | ||||
|         self.totalDuration = totalDuration | ||||
|         self.startPosition = startPosition | ||||
|         self.isFree = isFree | ||||
|         self.isPreview = isPreview | ||||
|     } | ||||
| } | ||||
							
								
								
									
										29
									
								
								SodaLive/Sources/Content/PlaybackTrackingRepository.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,29 @@ | ||||
| // | ||||
| //  PlaybackTrackingRepository.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/11. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import ObjectBox | ||||
|  | ||||
| final class PlaybackTrackingRepository { | ||||
|     private let objectBoxService = ObjectBoxService() | ||||
|      | ||||
|     func savePlaybackTracking(data: PlaybackTracking) -> Id { | ||||
|         return try! objectBoxService.playbackTrackingBox.put(data) | ||||
|     } | ||||
|      | ||||
|     func getPlaybackTracking(id: Id) -> PlaybackTracking? { | ||||
|         return try! objectBoxService.playbackTrackingBox.get(id) | ||||
|     } | ||||
|      | ||||
|     func getAllPlaybackTracking() -> [PlaybackTracking] { | ||||
|         return try! objectBoxService.playbackTrackingBox.all() | ||||
|     } | ||||
|      | ||||
|     func removeAllPlaybackTracking() { | ||||
|         try! objectBoxService.playbackTrackingBox.removeAll() | ||||
|     } | ||||
| } | ||||
| @@ -98,6 +98,12 @@ struct ContentView: View { | ||||
|             case .creatorNoticeWrite(let notice): | ||||
|                 CreatorNoticeWriteView(notice: notice) | ||||
|                  | ||||
|             case .modifyContent(let contentId): | ||||
|                 ContentModifyView(contentId: contentId) | ||||
|                  | ||||
|             case .contentDetail(let contentId): | ||||
|                 ContentDetailView(contentId: contentId) | ||||
|                  | ||||
|             default: | ||||
|                 EmptyView() | ||||
|                     .frame(width: 0, height: 0, alignment: .topLeading) | ||||
|   | ||||
 Yu Sung
					Yu Sung