From cf0607334a4e4b553752cb9ac9c5a83388247518 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Sun, 13 Aug 2023 20:59:49 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Podfile | 2 + Podfile.lock | 6 +- .../xcshareddata/swiftpm/Package.resolved | 9 + .../Contents.json | 21 + .../btn_audio_content_pause.png | Bin 0 -> 3129 bytes .../Contents.json | 21 + .../btn_audio_content_play.png | Bin 0 -> 3911 bytes .../btn_player_repeat.imageset/Contents.json | 21 + .../btn_player_repeat.png | Bin 0 -> 973 bytes .../Contents.json | 21 + .../btn_player_repeat_done.png | Bin 0 -> 1037 bytes .../Contents.json | 21 + .../ic_audio_content_heart_normal.png | Bin 0 -> 758 bytes .../Contents.json | 21 + .../ic_audio_content_heart_pressed.png | Bin 0 -> 606 bytes .../Contents.json | 21 + .../ic_audio_content_share.png | Bin 0 -> 708 bytes .../ic_circle_x_white.imageset/Contents.json | 21 + .../ic_circle_x_white.png | Bin 0 -> 1244 bytes .../ic_donation_white.imageset/Contents.json | 21 + .../ic_donation_white.png | Bin 0 -> 891 bytes .../Contents.json | 21 + .../ic_notice_exclamation_mark.png | Bin 0 -> 2989 bytes .../ic_review.imageset/Contents.json | 21 + .../ic_review.imageset/ic_review.png | Bin 0 -> 478 bytes .../ic_time_l.imageset/Contents.json | 21 + .../ic_time_l.imageset/ic_time_l.png | Bin 0 -> 910 bytes SodaLive/Sources/App/AppStep.swift | 4 + SodaLive/Sources/App/ObjectBoxService.swift | 34 ++ .../Sources/Content/ContentPlayManager.swift | 282 +++++++++++ .../Sources/Content/ContentRepository.swift | 8 +- .../Detail/AudioContentDeleteDialogView.swift | 97 ++++ .../Detail/AudioContentReportDialogView.swift | 104 ++++ .../Comment/AudioContentCommentItemView.swift | 89 ++++ .../Comment/AudioContentCommentListView.swift | 146 ++++++ .../AudioContentCommentListViewModel.swift | 124 +++++ .../Comment/AudioContentListReplyView.swift | 118 +++++ .../AudioContentListReplyViewModel.swift | 124 +++++ .../Comment/ContentDetailCommentView.swift | 89 ++++ .../GetAudioContentCommentListResponse.swift | 2 +- .../RegisterAudioContentCommentRequest.swift | 2 +- .../Detail/ContentDetailAnotherItemView.swift | 35 ++ .../ContentDetailCreatorProfileView.swift | 43 ++ .../Detail/ContentDetailInfoView.swift | 185 ++++++++ .../Detail/ContentDetailMenuView.swift | 83 ++++ .../Detail/ContentDetailMosaicView.swift | 45 ++ .../ContentDetailOtherContentView.swift | 51 ++ .../Detail/ContentDetailPlayView.swift | 171 +++++++ .../Detail/ContentDetailPurchaseButton.swift | 39 ++ .../Content/Detail/ContentDetailView.swift | 308 ++++++++++++ .../Detail/ContentDetailViewModel.swift | 446 ++++++++++++++++++ .../ContentOrderConfirmDialogView.swift | 155 ++++++ .../Detail/ContentOrderDialogView.swift | 97 ++++ .../Detail/LiveRoomDonationDialogView.swift | 257 ++++++++++ .../Detail/PutAudioContentLikeRequest.swift | 2 +- .../AudioContentDonationRequest.swift | 2 +- .../Content/Main/ContentMainItemView.swift | 2 +- .../Content/Modify/ContentModifyView.swift | 260 ++++++++++ .../Modify/ContentModifyViewModel.swift | 177 +++++++ .../Content/Modify/ModifyContentRequest.swift | 16 + .../Sources/Content/PlaybackTracking.swift | 44 ++ .../Content/PlaybackTrackingRepository.swift | 29 ++ SodaLive/Sources/ContentView.swift | 6 + 63 files changed, 3935 insertions(+), 10 deletions(-) create mode 100644 SodaLive/Resources/Assets.xcassets/btn_audio_content_pause.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/btn_audio_content_pause.imageset/btn_audio_content_pause.png create mode 100644 SodaLive/Resources/Assets.xcassets/btn_audio_content_play.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/btn_audio_content_play.imageset/btn_audio_content_play.png create mode 100644 SodaLive/Resources/Assets.xcassets/btn_player_repeat.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/btn_player_repeat.imageset/btn_player_repeat.png create mode 100644 SodaLive/Resources/Assets.xcassets/btn_player_repeat_done.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/btn_player_repeat_done.imageset/btn_player_repeat_done.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_normal.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_normal.imageset/ic_audio_content_heart_normal.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_pressed.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_audio_content_heart_pressed.imageset/ic_audio_content_heart_pressed.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_audio_content_share.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_audio_content_share.imageset/ic_audio_content_share.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_circle_x_white.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_circle_x_white.imageset/ic_circle_x_white.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_donation_white.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_donation_white.imageset/ic_donation_white.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_notice_exclamation_mark.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_notice_exclamation_mark.imageset/ic_notice_exclamation_mark.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_review.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_review.imageset/ic_review.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_time_l.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_time_l.imageset/ic_time_l.png create mode 100644 SodaLive/Sources/App/ObjectBoxService.swift create mode 100644 SodaLive/Sources/Content/ContentPlayManager.swift create mode 100644 SodaLive/Sources/Content/Detail/AudioContentDeleteDialogView.swift create mode 100644 SodaLive/Sources/Content/Detail/AudioContentReportDialogView.swift create mode 100644 SodaLive/Sources/Content/Detail/Comment/AudioContentCommentItemView.swift create mode 100644 SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListView.swift create mode 100644 SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListViewModel.swift create mode 100644 SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyView.swift create mode 100644 SodaLive/Sources/Content/Detail/Comment/AudioContentListReplyViewModel.swift create mode 100644 SodaLive/Sources/Content/Detail/Comment/ContentDetailCommentView.swift create mode 100644 SodaLive/Sources/Content/Detail/ContentDetailAnotherItemView.swift create mode 100644 SodaLive/Sources/Content/Detail/ContentDetailCreatorProfileView.swift create mode 100644 SodaLive/Sources/Content/Detail/ContentDetailInfoView.swift create mode 100644 SodaLive/Sources/Content/Detail/ContentDetailMenuView.swift create mode 100644 SodaLive/Sources/Content/Detail/ContentDetailMosaicView.swift create mode 100644 SodaLive/Sources/Content/Detail/ContentDetailOtherContentView.swift create mode 100644 SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift create mode 100644 SodaLive/Sources/Content/Detail/ContentDetailPurchaseButton.swift create mode 100644 SodaLive/Sources/Content/Detail/ContentDetailView.swift create mode 100644 SodaLive/Sources/Content/Detail/ContentDetailViewModel.swift create mode 100644 SodaLive/Sources/Content/Detail/ContentOrderConfirmDialogView.swift create mode 100644 SodaLive/Sources/Content/Detail/ContentOrderDialogView.swift create mode 100644 SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift create mode 100644 SodaLive/Sources/Content/Modify/ContentModifyView.swift create mode 100644 SodaLive/Sources/Content/Modify/ContentModifyViewModel.swift create mode 100644 SodaLive/Sources/Content/Modify/ModifyContentRequest.swift create mode 100644 SodaLive/Sources/Content/PlaybackTracking.swift create mode 100644 SodaLive/Sources/Content/PlaybackTrackingRepository.swift 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 0000000000000000000000000000000000000000..157293d7abe4f8c1836a75c21518177844f988aa GIT binary patch literal 3129 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 Tev^{*00000NkvXXu0mjf*pKR4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cc64b645562bc3b27d3a0c338edbe4608a34c598 GIT binary patch literal 3911 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e9f3249f06bd9c220cb9aea0524be97919db1c20 GIT binary patch literal 1037 zcmV+o1oHcdP)7S+4Z!6+qjPj? z-Pvc$Sd-z+Xk>kyq$ll4yE_(n!|NhR5~lr!hlk6%ySsJqjG6?1`}=!lUuw15cT<(( z_fE6fJQYuaqex#bl)>$V}?BFc2C7B+!aFW}qZY;8WM0v$yl9q&tk;&$W22L_V@(z%S z$X!MbaS0K#G6EW^9I5h^Ob?OtuZM^6O-4YYtPv8jDz6Nhg1|Be{nBlyJD8iBYsm-* z#WLzbk<5wcI3$T+h)$9u5d={~a&b~5iG~CKn0|cb_VGqegd`D)=%z?BqI4wpo!kt1&Gqvu^VjG zXGltd)Sl+XX{_0l!)RW1-p|i9?#FB`pyf<47azp%$6IC zMxSZuK!SPJFwc#v(PFg8-4a zZHOxMAW}`e6ZIT|;mI6g(FfKVn7N{Z1g1{E4zi?%wtOH)OEWVwpRjXHOh^T~$WBUI zR34Dx6xR^%y<)-gZm6$XgcGBeaoK=gOgtI`;x|0nBU*AP*=QU6d@LH!i}!*_Hl$pi zD*>cVv(q$h>6w&YjLW7^A-oywMK?7zp=Ik^#WA_QBORT@nCGaY2(Q@nd~TqtyRNak zdM7@~mRvwxF_o8hl+TW>GJInwmA2YE3FHjOUw657;r z;j&zGjpseWP)0U0--ZF5abtVOrmABuoY>6MtF!vV_~zU--oza10x`a-8Y?!|5udiW z=vE!3&qP78czQZ~XPK&;sBcU>R6?@Q8(LQ#pZ`gCLjnE*MfFcJ21Q`F00000NkvXX Hu0mjfDbwD% literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b76acf598bfbc944cbb6188595134fe93fd27132 GIT binary patch literal 758 zcmVtf z;xH73j}7mk1UJ9{6|7Uirh}bSkWB?K6)al;DIuulvWqtRvV5zVAnX`i^R^seLKna=CoK#1pBn4Wcq03&H9=wXsJ}!CP!0ZL4E&9OKczz41c00Y za+m{m3%E_y99R+)*gxnxf&#PAf@NPfazPt5aUD2{00bdW8#qe&igjIW&VO9|Vf63@ zKa4lHmj*H+o5B_N4EbdeKoEyUXIzz=${^doDf_jgqsUDpUH+&fDysY{E!76h6r%X5 zgGDAj^%_j_6+o?=*a&bNgX47S`0~l60TXg2Vfi4LzIk5rc>+W@lN6y&h!2tJ8|4|F zCqOLD7ZB?xhB#3|xv+RDiE*NUX(<{|?Y?C90(PqN(1qZ7>d4c2)~PPpB*s1!P;e+JH8YHgJsq z+d$gFH3n<~=>o15U>!(TaIFE$AX#<=X9ZXUseqioo#qrvWg$)3!co?MR34-YILZki oWkR}wb1q)n`M;uEbya=+1;E8aP$Q12`v3p{07*qoM6N<$g24n$NdN!< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bf69dab86ea4cbb0fc4cdb91e36d0c3a74f5f1f5 GIT binary patch literal 606 zcmV-k0-^nhP)jK~#7F?UzeZ z!axu}yCYPw6K~)V@CGcAjjk;h+`#e#Bqx9)WPuh-+$dEp#1n`o&?CUgDt_&B$M6gJ zhcIRF7R+Q|reDpFnI3>6{~Qea0X3>+n3t|$juk*WbsA|51`MD;L$v$fiTWdbKoQ=< z`lJ8A^$N_WrK(j3G(a1Q3u`+NHEQQDMN>t%Qjg`h{R0K*_VP{@ZHUeY2rPVfX|MG+ z1I4^5CU%}JPb+xj>P_`&LL$_RIir}62(RRZU^=;sAh>r|f>{d6e`bbXpaYB91wOtG zxoRvMr|$A-fKh0~Lb%PuyN02DSew7nM za_ShMIgTLW2y_E+%*CG|4M8`I#hVoJa8l|4pQk!8!A~wk5bd5i?CA+~fyl snFunQlG(^*j*_g>Opo&O8@`> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..973ae15daff6558c85d147d7bcdc8a7bc9c48098 GIT binary patch literal 708 zcmV;#0z3VQP)SLR0D+eT&o4C33wn4 z$f$r+C}ea%Dg-hn;HrG%3mFY?yaNU$_b@62{q>tnzM^o2a>Vz}WBMnC8h zsR@U+xvSjA-A4gAYKz6<4V|Csf*IwT&1P_{@?6N|2wdB^qV1?&!PqMxCdkk6z?FEY zuDe}v!ig|*z!7ZJ>-C;RNp&C(T#OEnHXOj{mcAw&RpoAk*X;qIDZIDrOyJHd7|SJs z2#L#sql!zJq*mcxka9LM;CzLPb_?h~OQSjI|n##w}i=hjf9qG}?AO81-{A0uu>@D*(|r zvl?=J%dv1~$TKdpQk_%@^^B<(q)8}T0Z5~G^P&*>-0UKm_^G7RO+fWC0lPaGb3!%?x1--6&MO+fpr qI2ex7JOUn-^BQpKigC*S=k*0TjNUApYF0l000005Fq0`>s?*bns_o}&lo9r~$4OGSy8suWrjgJS2u z`%Jn{_H}mNb~itmu1S;C`SHx_&PD`7ngxZPx-nm(hC3gK>7HsNcWs>`1MV% z*ZV^b>5*kv%DPBvA`M0AQ+*)m5?3K?N*1vqNRhHic8O#OQI})^qk>q>hDe)aCQ_<^ zNV=d{ma(WZBFM8Cl}MD3bVdfKEl9{g5gJik;f4f6{j6K5YYXD2c|md1gv>;wu@H4n z8fXa;au6-ADZHr30@3tMr*5h&h>O$?`LAoLf(Sy9`(5Qrl>`YnsED_z*_;STld>N! zEl9{geX~XFU|hD9TPpJ*j-0m00b87>LJ9?m8=*Jc^kS99HrJ=Q(^94Tn9k&K{qS|C}IKSYAKa<={Sp-HUDVkRTVwjA95gGl>SpFNR2k^R|)s6;(UzOcwU zP5>D@AU*c!zhvC!J=p`rY~m_{#)+R10S8o_q#X60HW;Fq^?3|pJA#h*y35yB6i2|9 za$ld5gZq7?crxA=2%anavqEk-$eV?k5e98gK5HuvWl$GRL z4j4d?j~T-vNg>DqLp1YG$PZGG6tTzGF@+%~q9ElIq}0i~XcSw<~x67Otsyb#lg1w}-4sdP{bR{yy2vW96sKY1hvbuQkFiJtX~NEx)@<>BAF~M0-D? zMV$PI;|(oH@)hfDGwmQ1{F|&Yly*QFKh*BQ?*d zQ?9&?vGkUq7BI=$x|ug8jd1HiQ9~XxSBhi`;-ZFuWt&^IJCTm^_Os?b)!-WaJGGbN zqJ6A|kA1joo>2QZEwg{M)N?W3<7s50ejuEQJeBJ2b#YVgX<0cyXInTHH;$-z8|_db z|EPKrL!E;Ku+DHm%>YgLk&}Z3Bs>ebMJZc|0>k~$6iIlM>`MwSnh-U|-VQfW5uy-H z&&a``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&di49o(aE{-7;jBn@sFT7+_UdZ~NNWH95H$zbKUnK7m50_Ph)2I&>=k3Q-%-lX#T<`KU8=kHo?$nm^({a4~& zBSE0?K(N4wIZH74%!K-PH{Ei~uT>uZW+cH=c16KVZzFSi?E*XH&cteyIL1DdX(!m=|O`r3IZ%7GwC|t_%&T-An zeIM>iMwb<3oi=+~EWE8})y1s!PmH%7=e^qE8ujV?q$-D!_#0v;*-!o6swdPQ?e^Y3 z$ii-deYltW-%j~Q(`Frda(7v_@~U)^GmSDArqoNVt*Ab7CQN^h+#f9eyLM2<@igYd3-b2MJoF5>vu;>E3#n`h&vb&}UBiS9pYuCm(z(emg#kZRR2mzdoR!rz}_8eS*nXtTG=aav0eO~iH z4x1yfI~`f{Y~0Q@1%I}sPMvkL>p>K!p}FdO<`sfvK?T3vO;`TCD`D-uZDIexCGYO- zxaE4oZO77opV!=sSh1G5KFGW*<(g}m!dVYT<@t4+q=lB&d99F7nX=w3;@dytKuF4E bU|97@eoo}khy| literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..199b9ed439b12663b486dd16c7f1332dc6882f06 GIT binary patch literal 2989 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 Q0K=HU)78&qol`;+0BEz~y8r+H literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6432a85aab294db522811a56f06c52be1618d8fa GIT binary patch literal 910 zcmV;919AL`P)6VX485d~tgSWHHv(E`Q z?65!h?&wZ}>g333zt$M&3ImlbvI&!f{3ShiOIyf(f$R?VmHJ7Un=jgDYNU%mhazjULvL@fRO?KSUS=J{x1`}be(US{e@bbBC zlNmLTdp1*axXNr3E( zHzWBHWC2w#0v&9Z)tkq<#RB@%fxFxM3g1YSC}4n=%)wQ*^1s_G>Ti?yn! zE;FAH#7y4VIy+(-5VCZ15PMc{V^gelyKiL_5rS(q-qo!0mJ3&YoGY^`P8u4 zjB9L*n4BaADLKsN0~f(+mQB9N1-&C3C|l0liEC^Nmx;GZC?U^GOir3kCtZhxW(OcD zf2jXI(I7POL%pUnJSx*8>VB;MbeXu)Sd;VY3-v<*RyeVii{M(-QwchULZ<4atHB9D zaRpUl2{L(kUozzR|u5DNL) z)V9%4bKXHmFMkMN`yk2W(D$VpXWn*VwdN#}2Xka?{i2bCsiZ(icF4rEue{Fl{BxUJ z>ruz+f-fD_r-LSO)&`lJ^BW2AHMvcjX)U}GchMv#H%aZ6z1HA}#Mod!$g=}Z9oHj! z$#E`x#`eBhkUt<8 literal 0 HcmV?d00001 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)