diff --git a/Podfile b/Podfile index c0148f1..920b610 100644 --- a/Podfile +++ b/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 diff --git a/Podfile.lock b/Podfile.lock index 3cf44b9..1f90cbc 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -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 diff --git a/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved index a9dd4ee..b9948a4 100644 --- a/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 diff --git a/SodaLive/Resources/Assets.xcassets/btn_audio_content_pause.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_audio_content_pause.imageset/Contents.json new file mode 100644 index 0000000..379e1b4 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_audio_content_pause.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_audio_content_pause.imageset/btn_audio_content_pause.png b/SodaLive/Resources/Assets.xcassets/btn_audio_content_pause.imageset/btn_audio_content_pause.png new file mode 100644 index 0000000..157293d Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_audio_content_pause.imageset/btn_audio_content_pause.png differ diff --git a/SodaLive/Resources/Assets.xcassets/btn_audio_content_play.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_audio_content_play.imageset/Contents.json new file mode 100644 index 0000000..9d6dd34 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_audio_content_play.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_audio_content_play.imageset/btn_audio_content_play.png b/SodaLive/Resources/Assets.xcassets/btn_audio_content_play.imageset/btn_audio_content_play.png new file mode 100644 index 0000000..cc64b64 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_audio_content_play.imageset/btn_audio_content_play.png differ diff --git a/SodaLive/Resources/Assets.xcassets/btn_player_repeat.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_player_repeat.imageset/Contents.json new file mode 100644 index 0000000..4759500 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_player_repeat.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_player_repeat.imageset/btn_player_repeat.png b/SodaLive/Resources/Assets.xcassets/btn_player_repeat.imageset/btn_player_repeat.png new file mode 100644 index 0000000..715983a Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_player_repeat.imageset/btn_player_repeat.png differ diff --git a/SodaLive/Resources/Assets.xcassets/btn_player_repeat_done.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_player_repeat_done.imageset/Contents.json new file mode 100644 index 0000000..93055a8 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_player_repeat_done.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_player_repeat_done.imageset/btn_player_repeat_done.png b/SodaLive/Resources/Assets.xcassets/btn_player_repeat_done.imageset/btn_player_repeat_done.png new file mode 100644 index 0000000..e9f3249 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_player_repeat_done.imageset/btn_player_repeat_done.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_normal.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_normal.imageset/Contents.json new file mode 100644 index 0000000..eba6d5a --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_normal.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_normal.imageset/ic_audio_content_heart_normal.png b/SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_normal.imageset/ic_audio_content_heart_normal.png new file mode 100644 index 0000000..b76acf5 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_normal.imageset/ic_audio_content_heart_normal.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_pressed.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_pressed.imageset/Contents.json new file mode 100644 index 0000000..0726e4c --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_pressed.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_pressed.imageset/ic_audio_content_heart_pressed.png b/SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_pressed.imageset/ic_audio_content_heart_pressed.png new file mode 100644 index 0000000..bf69dab Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_pressed.imageset/ic_audio_content_heart_pressed.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_audio_content_share.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_audio_content_share.imageset/Contents.json new file mode 100644 index 0000000..2a7e2f0 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_audio_content_share.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_audio_content_share.imageset/ic_audio_content_share.png b/SodaLive/Resources/Assets.xcassets/ic_audio_content_share.imageset/ic_audio_content_share.png new file mode 100644 index 0000000..973ae15 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_audio_content_share.imageset/ic_audio_content_share.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_circle_x_white.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_circle_x_white.imageset/Contents.json new file mode 100644 index 0000000..5b4ec1e --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_circle_x_white.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_circle_x_white.imageset/ic_circle_x_white.png b/SodaLive/Resources/Assets.xcassets/ic_circle_x_white.imageset/ic_circle_x_white.png new file mode 100644 index 0000000..89df2b8 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_circle_x_white.imageset/ic_circle_x_white.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_donation_white.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_donation_white.imageset/Contents.json new file mode 100644 index 0000000..22e0f98 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_donation_white.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_donation_white.imageset/ic_donation_white.png b/SodaLive/Resources/Assets.xcassets/ic_donation_white.imageset/ic_donation_white.png new file mode 100644 index 0000000..bdc3e78 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_donation_white.imageset/ic_donation_white.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_notice_exclamation_mark.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_notice_exclamation_mark.imageset/Contents.json new file mode 100644 index 0000000..4f79b79 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_notice_exclamation_mark.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_notice_exclamation_mark.imageset/ic_notice_exclamation_mark.png b/SodaLive/Resources/Assets.xcassets/ic_notice_exclamation_mark.imageset/ic_notice_exclamation_mark.png new file mode 100644 index 0000000..199b9ed Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_notice_exclamation_mark.imageset/ic_notice_exclamation_mark.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_review.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_review.imageset/Contents.json new file mode 100644 index 0000000..cfbb7dd --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_review.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_review.imageset/ic_review.png b/SodaLive/Resources/Assets.xcassets/ic_review.imageset/ic_review.png new file mode 100644 index 0000000..2f34ecf Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_review.imageset/ic_review.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_time_l.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_time_l.imageset/Contents.json new file mode 100644 index 0000000..e71e9d0 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_time_l.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_time_l.imageset/ic_time_l.png b/SodaLive/Resources/Assets.xcassets/ic_time_l.imageset/ic_time_l.png new file mode 100644 index 0000000..6432a85 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_time_l.imageset/ic_time_l.png differ diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index edd0611..85bffda 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -56,6 +56,10 @@ enum AppStep { case createContent + case modifyContent(contentId: Int) + + case contentDetail(contentId: Int) + case liveReservationComplete(response: MakeLiveReservationResponse) case creatorDetail(userId: Int) diff --git a/SodaLive/Sources/App/ObjectBoxService.swift b/SodaLive/Sources/App/ObjectBoxService.swift new file mode 100644 index 0000000..e0ca84f --- /dev/null +++ b/SodaLive/Sources/App/ObjectBoxService.swift @@ -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 + + 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) + } +} diff --git a/SodaLive/Sources/Content/ContentPlayManager.swift b/SodaLive/Sources/Content/ContentPlayManager.swift new file mode 100644 index 0000000..731945c --- /dev/null +++ b/SodaLive/Sources/Content/ContentPlayManager.swift @@ -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() + } + } +} diff --git a/SodaLive/Sources/Content/ContentRepository.swift b/SodaLive/Sources/Content/ContentRepository.swift index 6251705..c30ead6 100644 --- a/SodaLive/Sources/Content/ContentRepository.swift +++ b/SodaLive/Sources/Content/ContentRepository.swift @@ -22,11 +22,11 @@ final class ContentRepository { } func likeContent(audioContentId: Int) -> AnyPublisher { - 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 { - 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 { @@ -73,7 +73,7 @@ final class ContentRepository { return api.requestPublisher(.getNewContentOfTheme(theme: theme)) } - func donation(contentId: Int, coin: Int, comment: String) -> AnyPublisher { - return api.requestPublisher(.donation(request: AudioContentDonationRequest(audioContentId: contentId, donationCoin: coin, comment: comment))) + func donation(contentId: Int, can: Int, comment: String) -> AnyPublisher { + return api.requestPublisher(.donation(request: AudioContentDonationRequest(audioContentId: contentId, donationCan: can, comment: comment))) } } diff --git a/SodaLive/Sources/Content/Detail/AudioContentDeleteDialogView.swift b/SodaLive/Sources/Content/Detail/AudioContentDeleteDialogView.swift new file mode 100644 index 0000000..2d0a8c2 --- /dev/null +++ b/SodaLive/Sources/Content/Detail/AudioContentDeleteDialogView.swift @@ -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) + } +} diff --git a/SodaLive/Sources/Content/Detail/AudioContentReportDialogView.swift b/SodaLive/Sources/Content/Detail/AudioContentReportDialogView.swift new file mode 100644 index 0000000..67968f7 --- /dev/null +++ b/SodaLive/Sources/Content/Detail/AudioContentReportDialogView.swift @@ -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.. 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) + } + } +} diff --git a/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListView.swift b/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListView.swift new file mode 100644 index 0000000..b728022 --- /dev/null +++ b/SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListView.swift @@ -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..() + + @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.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) + } +} diff --git a/SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyView.swift b/SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyView.swift new file mode 100644 index 0000000..5f546a2 --- /dev/null +++ b/SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyView.swift @@ -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 + @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..() + + @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.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) + } +} diff --git a/SodaLive/Sources/Content/Detail/Comment/ContentDetailCommentView.swift b/SodaLive/Sources/Content/Detail/Comment/ContentDetailCommentView.swift new file mode 100644 index 0000000..1d8a6f6 --- /dev/null +++ b/SodaLive/Sources/Content/Detail/Comment/ContentDetailCommentView.swift @@ -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() + } + } + } +} diff --git a/SodaLive/Sources/Content/Detail/Comment/GetAudioContentCommentListResponse.swift b/SodaLive/Sources/Content/Detail/Comment/GetAudioContentCommentListResponse.swift index 0f2095c..2947407 100644 --- a/SodaLive/Sources/Content/Detail/Comment/GetAudioContentCommentListResponse.swift +++ b/SodaLive/Sources/Content/Detail/Comment/GetAudioContentCommentListResponse.swift @@ -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 } diff --git a/SodaLive/Sources/Content/Detail/Comment/RegisterAudioContentCommentRequest.swift b/SodaLive/Sources/Content/Detail/Comment/RegisterAudioContentCommentRequest.swift index fa9e682..4e72841 100644 --- a/SodaLive/Sources/Content/Detail/Comment/RegisterAudioContentCommentRequest.swift +++ b/SodaLive/Sources/Content/Detail/Comment/RegisterAudioContentCommentRequest.swift @@ -9,6 +9,6 @@ import Foundation struct RegisterAudioContentCommentRequest: Encodable { let comment: String - let audioContentId: Int + let contentId: Int let parentId: Int? } diff --git a/SodaLive/Sources/Content/Detail/ContentDetailAnotherItemView.swift b/SodaLive/Sources/Content/Detail/ContentDetailAnotherItemView.swift new file mode 100644 index 0000000..e2f775c --- /dev/null +++ b/SodaLive/Sources/Content/Detail/ContentDetailAnotherItemView.swift @@ -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) + } +} diff --git a/SodaLive/Sources/Content/Detail/ContentDetailCreatorProfileView.swift b/SodaLive/Sources/Content/Detail/ContentDetailCreatorProfileView.swift new file mode 100644 index 0000000..f715db6 --- /dev/null +++ b/SodaLive/Sources/Content/Detail/ContentDetailCreatorProfileView.swift @@ -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) + } + } + } + } + } +} diff --git a/SodaLive/Sources/Content/Detail/ContentDetailInfoView.swift b/SodaLive/Sources/Content/Detail/ContentDetailInfoView.swift new file mode 100644 index 0000000..d1e8347 --- /dev/null +++ b/SodaLive/Sources/Content/Detail/ContentDetailInfoView.swift @@ -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 + } +} diff --git a/SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift b/SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift new file mode 100644 index 0000000..6207cb4 --- /dev/null +++ b/SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift @@ -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]) + } + } + } +} diff --git a/SodaLive/Sources/Content/Detail/ContentDetailMosaicView.swift b/SodaLive/Sources/Content/Detail/ContentDetailMosaicView.swift new file mode 100644 index 0000000..163059c --- /dev/null +++ b/SodaLive/Sources/Content/Detail/ContentDetailMosaicView.swift @@ -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() + } + } + } +} diff --git a/SodaLive/Sources/Content/Detail/ContentDetailOtherContentView.swift b/SodaLive/Sources/Content/Detail/ContentDetailOtherContentView.swift new file mode 100644 index 0000000..d1d214c --- /dev/null +++ b/SodaLive/Sources/Content/Detail/ContentDetailOtherContentView.swift @@ -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.. 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 { + 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() + } +} diff --git a/SodaLive/Sources/Content/Detail/ContentDetailPurchaseButton.swift b/SodaLive/Sources/Content/Detail/ContentDetailPurchaseButton.swift new file mode 100644 index 0000000..815eade --- /dev/null +++ b/SodaLive/Sources/Content/Detail/ContentDetailPurchaseButton.swift @@ -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) + } +} diff --git a/SodaLive/Sources/Content/Detail/ContentDetailView.swift b/SodaLive/Sources/Content/Detail/ContentDetailView.swift new file mode 100644 index 0000000..3fa2ab0 --- /dev/null +++ b/SodaLive/Sources/Content/Detail/ContentDetailView.swift @@ -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() + } + } + } + } + } +} diff --git a/SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift b/SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift new file mode 100644 index 0000000..8f031cc --- /dev/null +++ b/SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift @@ -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() + + @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.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.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) + } + } +} diff --git a/SodaLive/Sources/Content/Detail/ContentOrderConfirmDialogView.swift b/SodaLive/Sources/Content/Detail/ContentOrderConfirmDialogView.swift new file mode 100644 index 0000000..9006e2e --- /dev/null +++ b/SodaLive/Sources/Content/Detail/ContentOrderConfirmDialogView.swift @@ -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) + } + } +} diff --git a/SodaLive/Sources/Content/Detail/ContentOrderDialogView.swift b/SodaLive/Sources/Content/Detail/ContentOrderDialogView.swift new file mode 100644 index 0000000..2ad9493 --- /dev/null +++ b/SodaLive/Sources/Content/Detail/ContentOrderDialogView.swift @@ -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")) + } + } + } +} diff --git a/SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift b/SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift new file mode 100644 index 0000000..51bb190 --- /dev/null +++ b/SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift @@ -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)) + } + } +} diff --git a/SodaLive/Sources/Content/Detail/PutAudioContentLikeRequest.swift b/SodaLive/Sources/Content/Detail/PutAudioContentLikeRequest.swift index 652777b..e2716ec 100644 --- a/SodaLive/Sources/Content/Detail/PutAudioContentLikeRequest.swift +++ b/SodaLive/Sources/Content/Detail/PutAudioContentLikeRequest.swift @@ -6,7 +6,7 @@ // struct PutAudioContentLikeRequest: Encodable { - let audioContentId: Int + let contentId: Int } struct PutAudioContentLikeResponse: Decodable { diff --git a/SodaLive/Sources/Content/Donation/AudioContentDonationRequest.swift b/SodaLive/Sources/Content/Donation/AudioContentDonationRequest.swift index 98bf078..b86883d 100644 --- a/SodaLive/Sources/Content/Donation/AudioContentDonationRequest.swift +++ b/SodaLive/Sources/Content/Donation/AudioContentDonationRequest.swift @@ -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" } diff --git a/SodaLive/Sources/Content/Main/ContentMainItemView.swift b/SodaLive/Sources/Content/Main/ContentMainItemView.swift index 9bb0f46..d1b28f3 100644 --- a/SodaLive/Sources/Content/Main/ContentMainItemView.swift +++ b/SodaLive/Sources/Content/Main/ContentMainItemView.swift @@ -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)) } } } diff --git a/SodaLive/Sources/Content/Modify/ContentModifyView.swift b/SodaLive/Sources/Content/Modify/ContentModifyView.swift new file mode 100644 index 0000000..7369d54 --- /dev/null +++ b/SodaLive/Sources/Content/Modify/ContentModifyView.swift @@ -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() + } + } + } + } + } + } +} diff --git a/SodaLive/Sources/Content/Modify/ContentModifyViewModel.swift b/SodaLive/Sources/Content/Modify/ContentModifyViewModel.swift new file mode 100644 index 0000000..f9fbce3 --- /dev/null +++ b/SodaLive/Sources/Content/Modify/ContentModifyViewModel.swift @@ -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() + + @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.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 + } +} diff --git a/SodaLive/Sources/Content/Modify/ModifyContentRequest.swift b/SodaLive/Sources/Content/Modify/ModifyContentRequest.swift new file mode 100644 index 0000000..31ccb02 --- /dev/null +++ b/SodaLive/Sources/Content/Modify/ModifyContentRequest.swift @@ -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 +} diff --git a/SodaLive/Sources/Content/PlaybackTracking.swift b/SodaLive/Sources/Content/PlaybackTracking.swift new file mode 100644 index 0000000..c80adc0 --- /dev/null +++ b/SodaLive/Sources/Content/PlaybackTracking.swift @@ -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 + } +} diff --git a/SodaLive/Sources/Content/PlaybackTrackingRepository.swift b/SodaLive/Sources/Content/PlaybackTrackingRepository.swift new file mode 100644 index 0000000..62a9025 --- /dev/null +++ b/SodaLive/Sources/Content/PlaybackTrackingRepository.swift @@ -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() + } +} diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index c5225d2..b45ee54 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -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)