라이브 방 추가
| @@ -9,6 +9,24 @@ | ||||
|         "version" : "1.2022062300.0" | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       "identity" : "agorartcengine_ios", | ||||
|       "kind" : "remoteSourceControl", | ||||
|       "location" : "https://github.com/AgoraIO/AgoraRtcEngine_iOS.git", | ||||
|       "state" : { | ||||
|         "revision" : "2e035dbfd39dea92ba9efd6447cd976fba85d5ff", | ||||
|         "version" : "4.2.2" | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       "identity" : "agorartm_ios", | ||||
|       "kind" : "remoteSourceControl", | ||||
|       "location" : "https://github.com/AgoraIO/AgoraRtm_iOS", | ||||
|       "state" : { | ||||
|         "revision" : "8d8d126da7e420798f39d1d95b6148eeb93971aa", | ||||
|         "version" : "1.4.10" | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       "identity" : "alamofire", | ||||
|       "kind" : "remoteSourceControl", | ||||
|   | ||||
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/btn_bar_play.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "btn_bar_play.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/btn_bar_play.imageset/btn_bar_play.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 513 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/btn_bar_stop.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "btn_bar_stop.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/btn_bar_stop.imageset/btn_bar_stop.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 294 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/btn_follow.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "btn_follow.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/btn_follow.imageset/btn_follow.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 4.2 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/btn_following.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "btn_following.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/btn_following.imageset/btn_following.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 4.4 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_badge_manager.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_badge_manager.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_badge_manager.imageset/ic_badge_manager.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.0 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_bottom_white.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_bottom_white.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_bottom_white.imageset/ic_bottom_white.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 401 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_donation.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_donation.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_donation.imageset/ic_donation.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 4.8 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_donation_message_list.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_donation_message_list.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_donation_message_list.imageset/ic_donation_message_list.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.5 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_donation_status.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_donation_status.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_donation_status.imageset/ic_donation_status.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.7 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_edit.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_edit.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_edit.imageset/ic_edit.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 491 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_live_detail_bottom.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_live_detail_bottom.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_live_detail_bottom.imageset/ic_live_detail_bottom.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 281 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_live_detail_top.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_live_detail_top.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_live_detail_top.imageset/ic_live_detail_top.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 280 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_mic_off.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_mic_off.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_mic_off.imageset/ic_mic_off.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 645 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_mic_on.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_mic_on.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_mic_on.imageset/ic_mic_on.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 564 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_noti_pause.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_noti_pause.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_noti_pause.imageset/ic_noti_pause.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 442 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_noti_play.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_noti_play.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_noti_play.imageset/ic_noti_play.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 670 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_noti_stop.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_noti_stop.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_noti_stop.imageset/ic_noti_stop.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 556 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_notice_normal.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_notice_normal.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_notice_normal.imageset/ic_notice_normal.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.6 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_notice_selected.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_notice_selected.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_notice_selected.imageset/ic_notice_selected.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.2 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_request_speak.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_request_speak.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_request_speak.imageset/ic_request_speak.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 5.0 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_share.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_share.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_share.imageset/ic_share.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 950 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_speaker_off.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_speaker_off.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_speaker_off.imageset/ic_speaker_off.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 5.1 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_speaker_on.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "images" : [ | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "1x" | ||||
|     }, | ||||
|     { | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "2x" | ||||
|     }, | ||||
|     { | ||||
|       "filename" : "ic_speaker_on.png", | ||||
|       "idiom" : "universal", | ||||
|       "scale" : "3x" | ||||
|     } | ||||
|   ], | ||||
|   "info" : { | ||||
|     "author" : "xcode", | ||||
|     "version" : 1 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_speaker_on.imageset/ic_speaker_on.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 5.8 KiB | 
							
								
								
									
										140
									
								
								SodaLive/Sources/Agora/Agora.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,140 @@ | ||||
| // | ||||
| //  Agora.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import AgoraRtcKit | ||||
| import AgoraRtmKit | ||||
|  | ||||
| final class Agora { | ||||
|     static let shared = Agora() | ||||
|      | ||||
|     var rtcEngineDelegate: AgoraRtcEngineDelegate? | ||||
|     var rtmDelegate: AgoraRtmDelegate? | ||||
|      | ||||
|     var rtcEngine: AgoraRtcEngineKit? | ||||
|      | ||||
|     var rtmKit: AgoraRtmKit? | ||||
|     var rtmChannel: AgoraRtmChannel? | ||||
|      | ||||
|     func initialize() { | ||||
|         rtcEngine = AgoraRtcEngineKit.sharedEngine(withAppId: AGORA_APP_ID, delegate: rtcEngineDelegate) | ||||
|         rtcEngine?.setChannelProfile(.liveBroadcasting) | ||||
|         rtcEngine?.enableAudio() | ||||
|         rtcEngine?.enableAudioVolumeIndication(500, smooth: 3, reportVad: true) | ||||
|          | ||||
|         rtmKit = AgoraRtmKit(appId: AGORA_APP_ID, delegate: rtmDelegate) | ||||
|     } | ||||
|      | ||||
|     func deInit() { | ||||
|         if let rtcEngine = rtcEngine { | ||||
|             rtcEngine.leaveChannel(nil) | ||||
|              | ||||
|             DispatchQueue.global(qos: .userInitiated).async { | ||||
|                 AgoraRtcEngineKit.destroy() | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         rtmChannel?.leave(completion: nil) | ||||
|         rtmKit?.logout(completion: nil) | ||||
|         rtcEngine = nil | ||||
|         rtmChannel = nil | ||||
|         rtmKit = nil | ||||
|     } | ||||
|      | ||||
|     func setRole(role: AgoraClientRole) { | ||||
|         self.rtcEngine?.setClientRole(role) | ||||
|     } | ||||
|      | ||||
|     func joinChannel( | ||||
|         roomInfo: GetRoomInfoResponse, | ||||
|         rtmChannelDelegate: AgoraRtmChannelDelegate, | ||||
|         onConnectSuccess: @escaping (Bool) -> Void, | ||||
|         onConnectFail: @escaping () -> Void | ||||
|     ) { | ||||
|         if rtmChannel != nil { | ||||
|             return | ||||
|         } | ||||
|          | ||||
|         let userId = UserDefaults.int(forKey: .userId) | ||||
|          | ||||
|         rtcEngine?.joinChannel( | ||||
|             byToken: roomInfo.rtcToken, | ||||
|             channelId: roomInfo.channelName, | ||||
|             info: nil, | ||||
|             uid: UInt(userId), | ||||
|             joinSuccess: nil | ||||
|         ) | ||||
|          | ||||
|         rtcEngine?.setAudioProfile(.musicHighQualityStereo) | ||||
|         rtcEngine?.setAudioScenario(.gameStreaming) | ||||
|          | ||||
|         rtmChannel = rtmKit?.createChannel( | ||||
|             withId: roomInfo.channelName, | ||||
|             delegate: rtmChannelDelegate | ||||
|         ) | ||||
|          | ||||
|         rtmKit?.login( | ||||
|             byToken: roomInfo.rtmToken, | ||||
|             user: String(userId), | ||||
|             completion: { [unowned self] loginErrorCode in | ||||
|                 if loginErrorCode == .ok { | ||||
|                     self.rtmChannel?.join(completion: { joinChannelErrorCode in | ||||
|                         if joinChannelErrorCode == .channelErrorOk { | ||||
|                             if userId == roomInfo.creatorId { | ||||
|                                 self.setRole(role: .broadcaster) | ||||
|                             } else { | ||||
|                                 self.setRole(role: .audience) | ||||
|                             } | ||||
|                              | ||||
|                             onConnectSuccess(userId == roomInfo.creatorId) | ||||
|                         } else { | ||||
|                             onConnectFail() | ||||
|                         } | ||||
|                     }) | ||||
|                 } else { | ||||
|                     onConnectFail() | ||||
|                 } | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
|      | ||||
|     func sendMessageToPeer(peerId: String, rawMessage: Data, completion: AgoraRtmSendPeerMessageBlock?) { | ||||
|         let option = AgoraRtmSendMessageOptions() | ||||
|         option.enableOfflineMessaging = false | ||||
|         option.enableHistoricalMessaging = false | ||||
|          | ||||
|         let message = AgoraRtmRawMessage(rawData: rawMessage, description: "") | ||||
|         rtmKit?.send(message, toPeer: peerId, completion: completion) | ||||
|     } | ||||
|      | ||||
|     func mute(_ isMute: Bool) { | ||||
|         rtcEngine?.muteLocalAudioStream(isMute) | ||||
|     } | ||||
|      | ||||
|     func speakerMute(_ isMute: Bool) { | ||||
|         rtcEngine?.muteAllRemoteAudioStreams(isMute) | ||||
|     } | ||||
|      | ||||
|     func sendMessageToGroup(textMessage: String, completion: @escaping AgoraRtmSendChannelMessageBlock) { | ||||
|         let message = AgoraRtmMessage(text: textMessage) | ||||
|         rtmChannel?.send(message, completion: completion) | ||||
|     } | ||||
|      | ||||
|     func sendRawMessageToGroup(rawMessage: LiveRoomChatRawMessage, completion: AgoraRtmSendChannelMessageBlock? = nil, fail: (() -> Void)? = nil) { | ||||
|         let encoder = JSONEncoder() | ||||
|         let jsonMessageData = try? encoder.encode(rawMessage) | ||||
|          | ||||
|         if let jsonMessageData = jsonMessageData { | ||||
|             let message = AgoraRtmRawMessage(rawData: jsonMessageData, description: "") | ||||
|             rtmChannel?.send(message, completion: completion) | ||||
|         } else { | ||||
|             if let fail = fail { | ||||
|                 fail() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -17,6 +17,7 @@ class AppState: ObservableObject { | ||||
|     @Published var isShowPlayer = false { | ||||
|         didSet { | ||||
|             if isShowPlayer { | ||||
|                 ContentPlayManager.shared.stopAudio() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -19,8 +19,8 @@ struct GetRoomListResponse: Decodable, Hashable { | ||||
|     let price: Int | ||||
|     let tags: [String] | ||||
|     let channelName: String? | ||||
|     let managerNickname: String | ||||
|     let managerId: Int | ||||
|     let creatorNickname: String | ||||
|     let creatorId: Int | ||||
|     let isReservation: Bool | ||||
|     let isPrivateRoom: Bool | ||||
| } | ||||
|   | ||||
| @@ -23,6 +23,19 @@ enum LiveApi { | ||||
|     case startLive(request: StartLiveRequest) | ||||
|     case cancelRoom(request: CancelLiveRequest) | ||||
|     case editLiveRoomInfo(roomId: Int, parameters: [MultipartFormData]) | ||||
|     case quitRoom(roomId: Int) | ||||
|     case getRoomInfo(roomId: Int) | ||||
|     case donation(request: LiveRoomDonationRequest) | ||||
|     case refundDonation(roomId: Int) | ||||
|     case setListener(request: SetManagerOrSpeakerOrAudienceRequest) | ||||
|     case setSpeaker(request: SetManagerOrSpeakerOrAudienceRequest) | ||||
|     case setManager(request: SetManagerOrSpeakerOrAudienceRequest) | ||||
|     case kickOut(request: LiveRoomKickOutRequest) | ||||
|     case donationStatus(roomId: Int) | ||||
|     case donationTotal(roomId: Int) | ||||
|     case getDonationMessageList(roomId: Int) | ||||
|     case deleteDonationMessage(roomId: Int, messageUUID: String) | ||||
|     case getUserProfile(roomId: Int, userId: Int) | ||||
| } | ||||
|  | ||||
| extension LiveApi: TargetType { | ||||
| @@ -73,19 +86,61 @@ extension LiveApi: TargetType { | ||||
|              | ||||
|         case .editLiveRoomInfo(let roomId, _): | ||||
|             return "/live/room/\(roomId)" | ||||
|              | ||||
|         case .quitRoom: | ||||
|             return "/live/room/quit" | ||||
|              | ||||
|         case .getRoomInfo(let roomId): | ||||
|             return "/live/room/info/\(roomId)" | ||||
|              | ||||
|         case .donation: | ||||
|             return "/live/room/donation" | ||||
|              | ||||
|         case .refundDonation(let roomId): | ||||
|             return "/live/room/donation/refund/\(roomId)" | ||||
|              | ||||
|         case .setListener: | ||||
|             return "/live/room/info/set/listener" | ||||
|              | ||||
|         case .setSpeaker: | ||||
|             return "/live/room/info/set/speaker" | ||||
|              | ||||
|         case .setManager: | ||||
|             return "/live/room/info/set/manager" | ||||
|              | ||||
|         case .kickOut: | ||||
|             return "/live/room/kick-out" | ||||
|              | ||||
|         case .donationStatus(let roomId): | ||||
|             return "/live/room/\(roomId)/donation-list" | ||||
|              | ||||
|         case .donationTotal(let roomId): | ||||
|             return "/live/room/\(roomId)/donation-total" | ||||
|              | ||||
|         case .getDonationMessageList: | ||||
|             return "/live/room/donation-message" | ||||
|              | ||||
|         case .deleteDonationMessage: | ||||
|             return "/live/room/donation-message" | ||||
|              | ||||
|         case .getUserProfile(let roomId, let userId): | ||||
|             return "/live/room/\(roomId)/profile/\(userId)" | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     var method: Moya.Method { | ||||
|         switch self { | ||||
|         case .roomList, .recentVisitRoomUsers, .getReservations, .getReservation, .getRoomDetail, .getTags, .getRecentRoomInfo: | ||||
|         case .roomList, .recentVisitRoomUsers, .getReservations, .getReservation, .getRoomDetail, .getTags, .getRecentRoomInfo, .getRoomInfo, .donationStatus, .donationTotal, .getDonationMessageList, .getUserProfile: | ||||
|             return .get | ||||
|              | ||||
|         case .makeReservation, .enterRoom, .createRoom: | ||||
|         case .makeReservation, .enterRoom, .createRoom, .quitRoom, .donation, .refundDonation, .kickOut: | ||||
|             return .post | ||||
|              | ||||
|         case .cancelReservation, .startLive, .cancelRoom, .editLiveRoomInfo: | ||||
|         case .setListener, .setSpeaker, .setManager, .cancelReservation, .startLive, .cancelRoom, .editLiveRoomInfo: | ||||
|             return .put | ||||
|              | ||||
|         case .deleteDonationMessage: | ||||
|             return .delete | ||||
|         } | ||||
|     } | ||||
|      | ||||
| @@ -107,7 +162,7 @@ extension LiveApi: TargetType { | ||||
|                 parameters: parameters, | ||||
|                 encoding: URLEncoding.queryString) | ||||
|              | ||||
|         case .recentVisitRoomUsers, .getTags, .getRecentRoomInfo: | ||||
|         case .recentVisitRoomUsers, .getTags, .getRecentRoomInfo, .getRoomInfo, .refundDonation, .donationStatus, .donationTotal, .getUserProfile: | ||||
|             return .requestPlain | ||||
|              | ||||
|         case .getReservations(let isActive): | ||||
| @@ -153,6 +208,24 @@ extension LiveApi: TargetType { | ||||
|              | ||||
|         case .editLiveRoomInfo(_, let parameters): | ||||
|             return .uploadMultipart(parameters) | ||||
|              | ||||
|         case .quitRoom(let roomId): | ||||
|             return .requestParameters(parameters: ["id": roomId], encoding: URLEncoding.queryString) | ||||
|              | ||||
|         case .donation(let request): | ||||
|             return .requestJSONEncodable(request) | ||||
|              | ||||
|         case .setListener(let request), .setSpeaker(let request), .setManager(let request): | ||||
|             return .requestJSONEncodable(request) | ||||
|              | ||||
|         case .kickOut(let request): | ||||
|             return .requestJSONEncodable(request) | ||||
|              | ||||
|         case .getDonationMessageList(let roomId): | ||||
|             return .requestParameters(parameters: ["roomId": roomId], encoding: URLEncoding.queryString) | ||||
|              | ||||
|         case .deleteDonationMessage(let roomId, let messageUUID): | ||||
|             return .requestJSONEncodable(DeleteLiveRoomDonationMessage(roomId: roomId, messageUUID: messageUUID)) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|   | ||||
| @@ -64,4 +64,56 @@ final class LiveRepository { | ||||
|     func editLiveRoomInfo(roomId: Int, parameters: [MultipartFormData]) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.editLiveRoomInfo(roomId: roomId, parameters: parameters)) | ||||
|     } | ||||
|      | ||||
|     func quitRoom(roomId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.quitRoom(roomId: roomId)) | ||||
|     } | ||||
|      | ||||
|     func getRoomInfo(roomId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getRoomInfo(roomId: roomId)) | ||||
|     } | ||||
|      | ||||
|     func donation(roomId: Int, can: Int, message: String = "") -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.donation(request: LiveRoomDonationRequest(roomId: roomId, can: can, message: message))) | ||||
|     } | ||||
|      | ||||
|     func refundDonation(roomId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.refundDonation(roomId: roomId)) | ||||
|     } | ||||
|      | ||||
|     func setListener(roomId: Int, userId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.setListener(request: SetManagerOrSpeakerOrAudienceRequest(roomId: roomId, memberId: userId))) | ||||
|     } | ||||
|      | ||||
|     func setSpeaker(roomId: Int, userId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.setSpeaker(request: SetManagerOrSpeakerOrAudienceRequest(roomId: roomId, memberId: userId))) | ||||
|     } | ||||
|      | ||||
|     func setManager(roomId: Int, userId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         api.requestPublisher(.setManager(request: SetManagerOrSpeakerOrAudienceRequest(roomId: roomId, memberId: userId))) | ||||
|     } | ||||
|      | ||||
|     func kickOut(roomId: Int, userId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.kickOut(request: LiveRoomKickOutRequest(roomId: roomId, userId: userId))) | ||||
|     } | ||||
|      | ||||
|     func donationStatus(roomId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.donationStatus(roomId: roomId)) | ||||
|     } | ||||
|      | ||||
|     func getTotalDoantionCan(roomId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.donationTotal(roomId: roomId)) | ||||
|     } | ||||
|      | ||||
|     func getDonationMessageList(roomId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getDonationMessageList(roomId: roomId)) | ||||
|     } | ||||
|      | ||||
|     func deleteDonationMessage(roomId: Int, messageUUID: String) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.deleteDonationMessage(roomId: roomId, messageUUID: messageUUID)) | ||||
|     } | ||||
|      | ||||
|     func getUserProfile(roomId: Int, userId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         api.requestPublisher(.getUserProfile(roomId: roomId, userId: userId)) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -58,7 +58,9 @@ struct LiveView: View { | ||||
|                             if viewModel.liveNowItems.count > 0 { | ||||
|                                 SectionLiveNowView( | ||||
|                                     items: viewModel.liveNowItems, | ||||
|                                     onClickParticipant: {_ in}, | ||||
|                                     onClickParticipant: { | ||||
|                                         viewModel.enterRoom(roomId: $0) | ||||
|                                     }, | ||||
|                                     onTapCreateLive: { | ||||
|                                         AppState.shared.setAppStep(step: .createLive(timeSettingMode: .NOW, onSuccess: onCreateSuccess)) | ||||
|                                     } | ||||
|   | ||||
| @@ -38,7 +38,7 @@ struct LiveNowAllItemView: View { | ||||
|                 VStack(alignment: .leading, spacing: 0) { | ||||
|                     HStack(alignment: .top, spacing: 0) { | ||||
|                         VStack(alignment: .leading, spacing: 0) { | ||||
|                             Text(item.managerNickname) | ||||
|                             Text(item.creatorNickname) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 11.3)) | ||||
|                                 .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                              | ||||
|   | ||||
| @@ -74,7 +74,7 @@ struct LiveNowItemView: View { | ||||
|                      | ||||
|                     Spacer() | ||||
|                      | ||||
|                     Text("\(item.managerNickname)") | ||||
|                     Text("\(item.creatorNickname)") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                         .foregroundColor(Color.white) | ||||
|                 } | ||||
| @@ -101,8 +101,8 @@ struct LiveNowItemView_Previews: PreviewProvider { | ||||
|                 price: 0, | ||||
|                 tags: ["팬미팅", "힐링"], | ||||
|                 channelName: nil, | ||||
|                 managerNickname: "user8", | ||||
|                 managerId: 19, | ||||
|                 creatorNickname: "user8", | ||||
|                 creatorId: 19, | ||||
|                 isReservation: false, | ||||
|                 isPrivateRoom: true | ||||
|             ) | ||||
|   | ||||
| @@ -41,7 +41,7 @@ struct LiveReservationAllItemView: View { | ||||
|                             .font(.custom(Font.medium.rawValue, size: 9.3)) | ||||
|                             .foregroundColor(Color(hex: "ffd300")) | ||||
|                          | ||||
|                         Text(item.managerNickname) | ||||
|                         Text(item.creatorNickname) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 11.3)) | ||||
|                             .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                             .padding(.top, 10) | ||||
|   | ||||
| @@ -41,7 +41,7 @@ struct LiveReservationItemView: View { | ||||
|                             .font(.custom(Font.medium.rawValue, size: 9.3)) | ||||
|                             .foregroundColor(Color(hex: "ffd300")) | ||||
|                          | ||||
|                         Text(item.managerNickname) | ||||
|                         Text(item.creatorNickname) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 11.3)) | ||||
|                             .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                             .padding(.top, 10) | ||||
| @@ -114,8 +114,8 @@ struct LiveReservationItemView_Previews: PreviewProvider { | ||||
|             price: 0, | ||||
|             tags: ["팬미팅", "힐링"], | ||||
|             channelName: nil, | ||||
|             managerNickname: "user8", | ||||
|             managerId: 19, | ||||
|             creatorNickname: "user8", | ||||
|             creatorId: 19, | ||||
|             isReservation: false, | ||||
|             isPrivateRoom: true | ||||
|         )) | ||||
|   | ||||
| @@ -53,7 +53,7 @@ struct MyLiveReservationItemView: View { | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 9.3)) | ||||
|                                 .foregroundColor(Color(hex: "ffd300")) | ||||
|                              | ||||
|                             Text(item.managerNickname) | ||||
|                             Text(item.creatorNickname) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 11.3)) | ||||
|                                 .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                                 .padding(.top, 10) | ||||
| @@ -103,8 +103,8 @@ struct MyLiveReservationItemView_Previews: PreviewProvider { | ||||
|                 price: 0, | ||||
|                 tags: ["팬미팅", "힐링"], | ||||
|                 channelName: nil, | ||||
|                 managerNickname: "user8", | ||||
|                 managerId: 19, | ||||
|                 creatorNickname: "user8", | ||||
|                 creatorId: 19, | ||||
|                 isReservation: false, | ||||
|                 isPrivateRoom: true | ||||
|             ), | ||||
|   | ||||
| @@ -53,7 +53,7 @@ struct SectionLiveReservationView: View { | ||||
|                     ForEach(0..<items.count, id: \.self) { index in | ||||
|                         let item = items[index] | ||||
|                          | ||||
|                         if item.managerId == UserDefaults.int(forKey: .userId) { | ||||
|                         if item.creatorId == UserDefaults.int(forKey: .userId) { | ||||
|                             MyLiveReservationItemView(item: item, index: index) | ||||
|                                 .contentShape(Rectangle()) | ||||
|                                 .onTapGesture { | ||||
|   | ||||
							
								
								
									
										42
									
								
								SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,42 @@ | ||||
| // | ||||
| //  LiveRoomChat.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| enum LiveRoomChatType: String { | ||||
|     case CHAT, DONATION, JOIN | ||||
| } | ||||
|  | ||||
| protocol LiveRoomChat { | ||||
|     var type: LiveRoomChatType { get set } | ||||
| } | ||||
|  | ||||
| struct LiveRoomNormalChat: LiveRoomChat { | ||||
|     let userId: Int | ||||
|     let profileUrl: String | ||||
|     let nickname: String | ||||
|     let rank: Int | ||||
|     let chat: String | ||||
|      | ||||
|     var type: LiveRoomChatType = .CHAT | ||||
| } | ||||
|  | ||||
| struct LiveRoomDonationChat: LiveRoomChat { | ||||
|     let profileUrl: String | ||||
|     let nickname: String | ||||
|     let chat: String | ||||
|     let can: Int | ||||
|     let donationMessage: String | ||||
|      | ||||
|     var type: LiveRoomChatType = .DONATION | ||||
| } | ||||
|  | ||||
| struct LiveRoomJoinChat: LiveRoomChat { | ||||
|     let nickname: String | ||||
|      | ||||
|     var type: LiveRoomChatType = .JOIN | ||||
| } | ||||
							
								
								
									
										130
									
								
								SodaLive/Sources/Live/Room/Chat/LiveRoomChatItemView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,130 @@ | ||||
| // | ||||
| //  LiveRoomChatItemView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct LiveRoomChatItemView: View { | ||||
|      | ||||
|     let chatMessage: LiveRoomNormalChat | ||||
|     let onClickProfile: () -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         HStack(alignment: .top, spacing: 13.3) { | ||||
|             ZStack { | ||||
|                 switch chatMessage.rank + 1 { | ||||
|                 case -2: | ||||
|                     Color(hex: "4999e3") | ||||
|                         .frame(width: 33.3, height: 33.3, alignment: .top) | ||||
|                         .clipShape(Circle()) | ||||
|                      | ||||
|                 case -1: | ||||
|                     Color(hex: "6f3dec") | ||||
|                         .frame(width: 33.3, height: 33.3, alignment: .top) | ||||
|                         .clipShape(Circle()) | ||||
|                      | ||||
|                 case 1: | ||||
|                     Color(hex: "fdca2f") | ||||
|                         .frame(width: 33.3, height: 33.3, alignment: .top) | ||||
|                         .clipShape(Circle()) | ||||
|                      | ||||
|                 case 2: | ||||
|                     Color(hex: "dcdcdc") | ||||
|                         .frame(width: 33.3, height: 33.3, alignment: .top) | ||||
|                         .clipShape(Circle()) | ||||
|                      | ||||
|                 case 3: | ||||
|                     Color(hex: "c67e4a") | ||||
|                         .frame(width: 33.3, height: 33.3, alignment: .top) | ||||
|                         .clipShape(Circle()) | ||||
|                      | ||||
|                 default: | ||||
|                     Color(hex: "bbbbbb") | ||||
|                         .frame(width: 33.3, height: 33.3, alignment: .top) | ||||
|                         .clipShape(Circle()) | ||||
|                 } | ||||
|                  | ||||
|                 KFImage(URL(string: chatMessage.profileUrl)) | ||||
|                     .resizable() | ||||
|                     .scaledToFill() | ||||
|                     .frame(width: 30, height: 30, alignment: .top) | ||||
|                     .clipShape(Circle()) | ||||
|                  | ||||
|                 VStack(alignment: .trailing, spacing: 0) { | ||||
|                     Spacer() | ||||
|                      | ||||
|                     switch chatMessage.rank + 1 { | ||||
|                     case -2: | ||||
|                         Image("ic_badge_manager") | ||||
|                             .resizable() | ||||
|                             .frame(width: 16.7, height: 16.7) | ||||
|                          | ||||
|                     case -1: | ||||
|                         Image("ic_crown") | ||||
|                             .resizable() | ||||
|                             .frame(width: 16.7, height: 16.7) | ||||
|                          | ||||
|                     case 1: | ||||
|                         Image("ic_crown_1") | ||||
|                             .resizable() | ||||
|                             .frame(width: 16.7, height: 16.7) | ||||
|                          | ||||
|                     case 2: | ||||
|                         Image("ic_crown_2") | ||||
|                             .resizable() | ||||
|                             .frame(width: 16.7, height: 16.7) | ||||
|                          | ||||
|                     case 3: | ||||
|                         Image("ic_crown_3") | ||||
|                             .resizable() | ||||
|                             .frame(width: 16.7, height: 16.7) | ||||
|                          | ||||
|                     default: | ||||
|                         EmptyView() | ||||
|                     } | ||||
|                 } | ||||
|                 .frame(width: 33.3, height: 33.3, alignment: .trailing) | ||||
|             } | ||||
|             .onTapGesture { onClickProfile() } | ||||
|              | ||||
|             VStack(alignment: .leading, spacing: 6.7) { | ||||
|                 HStack(spacing: 5) { | ||||
|                     if chatMessage.rank == -3 { | ||||
|                         Text("스탭") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                             .foregroundColor(.white) | ||||
|                             .padding(2) | ||||
|                             .background(Color(hex: "4999e3")) | ||||
|                             .cornerRadius(2) | ||||
|                     } | ||||
|                      | ||||
|                     Text(chatMessage.nickname) | ||||
|                         .font(.custom(Font.light.rawValue, size: 12)) | ||||
|                         .foregroundColor(.white) | ||||
|                 } | ||||
|                  | ||||
|                 Text(chatMessage.chat) | ||||
|                     .font(.custom(Font.medium.rawValue, size: 14)) | ||||
|                     .foregroundColor(.white) | ||||
|                     .multilineTextAlignment(.leading) | ||||
|                     .lineLimit(nil) | ||||
|                     .lineSpacing(6) | ||||
|                     .fixedSize(horizontal: false, vertical: true) | ||||
|             } | ||||
|             .padding(.horizontal, 8) | ||||
|             .padding(.vertical, 5.3) | ||||
|             .background( | ||||
|                 UserDefaults.int(forKey: .userId) == chatMessage.userId ? | ||||
|                 Color(hex: "9970ff").opacity(0.6) : | ||||
|                     Color.black.opacity(0.6) | ||||
|             ) | ||||
|             .cornerRadius(3.3) | ||||
|         } | ||||
|         .frame(width: screenSize().width - 86, alignment: .leading) | ||||
|         .padding(.leading, 20) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										19
									
								
								SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,19 @@ | ||||
| // | ||||
| //  LiveRoomChatRawMessage.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct LiveRoomChatRawMessage: Codable { | ||||
|     enum LiveRoomChatRawMessageType: String, Codable { | ||||
|         case DONATION, EDIT_ROOM_INFO, SET_MANAGER | ||||
|     } | ||||
|      | ||||
|     let type: LiveRoomChatRawMessageType | ||||
|     let message: String | ||||
|     let can: Int | ||||
|     let donationMessage: String? | ||||
| } | ||||
| @@ -0,0 +1,70 @@ | ||||
| // | ||||
| //  LiveRoomDonationChatItemView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct LiveRoomDonationChatItemView: View { | ||||
|      | ||||
|     let chatMessage: LiveRoomDonationChat | ||||
|      | ||||
|     var body: some View { | ||||
|         HStack(spacing: 13.3) { | ||||
|             ZStack(alignment: .bottomTrailing) { | ||||
|                 KFImage(URL(string: chatMessage.profileUrl)) | ||||
|                     .resizable() | ||||
|                     .scaledToFill() | ||||
|                     .frame(width: 33.3, height: 33.3, alignment: .top) | ||||
|                     .clipped() | ||||
|                     .cornerRadius(23.3) | ||||
|                  | ||||
|                 Image("ic_can") | ||||
|             } | ||||
|              | ||||
|             VStack(alignment: .leading, spacing: 6.7) { | ||||
|                 HStack(spacing: 0) { | ||||
|                     Text(chatMessage.nickname) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                         .foregroundColor(.white) | ||||
|                      | ||||
|                     Text("님이") | ||||
|                         .font(.custom(Font.light.rawValue, size: 12)) | ||||
|                         .foregroundColor(.white) | ||||
|                 } | ||||
|                  | ||||
|                 HStack(spacing: 0) { | ||||
|                     Text("\(chatMessage.can)캔") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 13)) | ||||
|                         .foregroundColor(Color(hex: "fdca2f")) | ||||
|                      | ||||
|                     Text("을 후원하셨습니다.") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 13)) | ||||
|                         .foregroundColor(.white) | ||||
|                 } | ||||
|                  | ||||
|                 if !chatMessage.donationMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | ||||
|                     Text("\"\(chatMessage.donationMessage)\"") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 13)) | ||||
|                         .foregroundColor(.white) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         .padding(13) | ||||
|         .frame(width: screenSize().width - 86, alignment: .leading) | ||||
|         .background( | ||||
|             chatMessage.can >= 100000 ? Color(hex: "c25264") : | ||||
|                 chatMessage.can >= 50000 ? Color(hex: "d85e37").opacity(0.9) : | ||||
|                 chatMessage.can >= 10000 ? Color(hex: "d38c38").opacity(0.9) : | ||||
|                 chatMessage.can >= 5000 ? Color(hex: "59548f").opacity(0.9) : | ||||
|                 chatMessage.can >= 1000 ? Color(hex: "4d6aa4").opacity(0.9) : | ||||
|                 chatMessage.can >= 500 ? Color(hex: "2d7390").opacity(0.9) : | ||||
|                 Color(hex: "548f7d").opacity(0.9) | ||||
|         ) | ||||
|         .cornerRadius(10) | ||||
|         .padding(.leading, 20) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,34 @@ | ||||
| // | ||||
| //  LiveRoomJoinChatItemView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct LiveRoomJoinChatItemView: View { | ||||
|      | ||||
|     let chatMessage: LiveRoomJoinChat | ||||
|      | ||||
|     var body: some View { | ||||
|         HStack(spacing: 0) { | ||||
|             Text("'") | ||||
|                 .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|              | ||||
|             Text(chatMessage.nickname) | ||||
|                 .font(.custom(Font.bold.rawValue, size: 12)) | ||||
|                 .foregroundColor(Color(hex: "ffdc00")) | ||||
|              | ||||
|             Text("'님이 입장하셨습니다.") | ||||
|                 .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|         } | ||||
|         .padding(.vertical, 6.7) | ||||
|         .frame(width: screenSize().width - 86) | ||||
|         .background(Color(hex: "3d2a6c")) | ||||
|         .cornerRadius(4.7) | ||||
|         .padding(.leading, 20) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| // | ||||
| //  DeleteLiveRoomDonationMessage.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct DeleteLiveRoomDonationMessage: Encodable { | ||||
|     let roomId: Int | ||||
|     let messageUUID: String | ||||
| } | ||||
| @@ -485,7 +485,7 @@ struct LiveDetailView: View { | ||||
|          | ||||
|         if room.numberOfParticipants > 0 { | ||||
|             HStack(spacing: 6.7) { | ||||
|                 Image(isExpandParticipantArea ? "ic_suda_detail_top" : "ic_suda_detail_bottom") | ||||
|                 Image(isExpandParticipantArea ? "ic_live_detail_top" : "ic_live_detail_bottom") | ||||
|                     .resizable() | ||||
|                     .frame(width: 20, height: 20) | ||||
|                  | ||||
|   | ||||
							
								
								
									
										72
									
								
								SodaLive/Sources/Live/Room/Dialog/LiveRoomDialogView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,72 @@ | ||||
| // | ||||
| //  LiveRoomDialogView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct LiveRoomDialogView: View { | ||||
|      | ||||
|     let content: String | ||||
|      | ||||
|     let cancelTitle: String? | ||||
|     let cancelAction: (() -> Void)? | ||||
|      | ||||
|     let confirmTitle: String? | ||||
|     let confirmAction: (() -> Void)? | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading, spacing: 0) { | ||||
|             HStack(alignment: .center, spacing: 11.7) { | ||||
|                 Image("ic_request_speak") | ||||
|                     .resizable() | ||||
|                     .frame(width: 36, height: 36) | ||||
|                  | ||||
|                 Text(content) | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|             } | ||||
|             .padding(.horizontal, 26.7) | ||||
|              | ||||
|             HStack(spacing: 10) { | ||||
|                 Spacer() | ||||
|                  | ||||
|                 if let cancelTitle = cancelTitle, let cancelAction = cancelAction { | ||||
|                     Text(cancelTitle) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                         .foregroundColor(.white) | ||||
|                         .padding(.horizontal, 8) | ||||
|                         .padding(.vertical, 8.3) | ||||
|                         .background(Color.white.opacity(0.2)) | ||||
|                         .cornerRadius(13.3) | ||||
|                         .overlay( | ||||
|                             RoundedRectangle(cornerRadius: 13.3) | ||||
|                                 .strokeBorder() | ||||
|                                 .foregroundColor(.white) | ||||
|                         ) | ||||
|                         .onTapGesture { cancelAction() } | ||||
|                 } | ||||
|                  | ||||
|                 if let confirmTitle = confirmTitle, let confirmAction = confirmAction { | ||||
|                     Text(confirmTitle) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                         .foregroundColor(Color(hex: "9970ff")) | ||||
|                         .padding(.horizontal, 8) | ||||
|                         .padding(.vertical, 8.3) | ||||
|                         .background(Color.white) | ||||
|                         .cornerRadius(13.3) | ||||
|                         .onTapGesture { confirmAction() } | ||||
|                 } | ||||
|             } | ||||
|             .padding(.horizontal, 26.7) | ||||
|             .padding(.top, confirmTitle != nil || cancelTitle != nil ? 10 : 0) | ||||
|         } | ||||
|         .padding(.vertical, 20) | ||||
|         .background(Color(hex: "9970ff")) | ||||
|         .cornerRadius(16.7) | ||||
|         .padding(.horizontal, 26.7) | ||||
|         .frame(width: screenSize().width) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,118 @@ | ||||
| // | ||||
| //  LiveRoomDonationMessageDialog.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/15. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct LiveRoomDonationMessageDialog: View { | ||||
|      | ||||
|     @Binding var isShowing: Bool | ||||
|     @StateObject var viewModel = LiveRoomViewModel() | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             Color.black | ||||
|                 .opacity(0.7) | ||||
|                 .ignoresSafeArea() | ||||
|                 .onTapGesture { | ||||
|                     hideKeyboard() | ||||
|                 } | ||||
|              | ||||
|             VStack(spacing: 0) { | ||||
|                 HStack(spacing: 0) { | ||||
|                     Text("후원메시지") | ||||
|                         .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                      | ||||
|                     Text("(\(viewModel.donationMessageCount))") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                      | ||||
|                     Spacer() | ||||
|                      | ||||
|                     Text("닫기") | ||||
|                         .font(.custom(Font.light.rawValue, size: 14.7)) | ||||
|                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                         .onTapGesture { isShowing = false } | ||||
|                 } | ||||
|                  | ||||
|                 ScrollView { | ||||
|                     if viewModel.donationMessageList.count > 0 { | ||||
|                         LazyVStack(spacing: 10.7) { | ||||
|                             ForEach(0..<viewModel.donationMessageList.count, id: \.self) { index in | ||||
|                                 let donationMessage = viewModel.donationMessageList[index] | ||||
|                                  | ||||
|                                 HStack(alignment: .top, spacing: 0) { | ||||
|                                     VStack(alignment: .leading, spacing: 8) { | ||||
|                                         Text("\(donationMessage.nickname)님이") | ||||
|                                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                                             .foregroundColor(.white) | ||||
|                                          | ||||
|                                         Text("\(donationMessage.canMessage)") | ||||
|                                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                                             .foregroundColor(.white) | ||||
|                                          | ||||
|                                         Text("'\(donationMessage.donationMessage)'") | ||||
|                                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                                             .foregroundColor(.white) | ||||
|                                     } | ||||
|                                      | ||||
|                                     Spacer() | ||||
|                                      | ||||
|                                     Image("ic_close_white") | ||||
|                                         .resizable() | ||||
|                                         .frame(width: 13.3, height: 13.3) | ||||
|                                         .onTapGesture { | ||||
|                                             viewModel.deleteDonationMessage(uuid: donationMessage.uuid) | ||||
|                                         } | ||||
|                                 } | ||||
|                                 .padding(13.3) | ||||
|                                 .background(Color(hex: "333333")) | ||||
|                                 .cornerRadius(5.3) | ||||
|                                 .onTapGesture { | ||||
|                                     UIPasteboard.general.string = donationMessage.donationMessage | ||||
|                                     self.viewModel.errorMessage = "후원 메시지가 복사되었습니다." | ||||
|                                     self.viewModel.isShowPopup = true | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         .padding(.top, 18.7) | ||||
|                     } else { | ||||
|                         Text("후원메시지가 없습니다.") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color(hex: "eeeeee")) | ||||
|                             .padding(.top, 30) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .padding(20) | ||||
|             .background(Color(hex: "222222")) | ||||
|             .cornerRadius(8) | ||||
|              | ||||
|             if viewModel.isLoading { | ||||
|                 LoadingView() | ||||
|             } | ||||
|         } | ||||
|         .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) { | ||||
|             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(.leading) | ||||
|                     .cornerRadius(20) | ||||
|                     .padding(.bottom, 66.7) | ||||
|                 Spacer() | ||||
|             } | ||||
|         } | ||||
|         .onAppear { | ||||
|             viewModel.getDonationMessageList() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,95 @@ | ||||
| // | ||||
| //  LiveRoomDonationRankingDialog.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/15. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct LiveRoomDonationRankingDialog: View { | ||||
|      | ||||
|     @Binding var isShowing: Bool | ||||
|     @StateObject var viewModel = LiveRoomViewModel() | ||||
|      | ||||
|     let columns = [GridItem(.flexible())] | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             VStack(spacing: 0) { | ||||
|                 HStack(spacing: 0) { | ||||
|                     Text("현재 라이브 후원랭킹") | ||||
|                         .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                      | ||||
|                     Spacer() | ||||
|                      | ||||
|                     Image("ic_close_white") | ||||
|                         .onTapGesture { isShowing = false } | ||||
|                 } | ||||
|                  | ||||
|                 if let donationStatus = viewModel.donationStatus { | ||||
|                     LiveRoomDonationRankingTotalCanView(totalCan: donationStatus.totalCan) | ||||
|                         .padding(.top, 25) | ||||
|                      | ||||
|                     HStack(spacing: 0) { | ||||
|                         Text("전체") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color(hex: "eeeeee")) | ||||
|                          | ||||
|                         Text("\(donationStatus.totalCount)") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                             .padding(.leading, 6.7) | ||||
|                          | ||||
|                         Text("명") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                             .foregroundColor(Color(hex: "777777")) | ||||
|                          | ||||
|                         Spacer() | ||||
|                     } | ||||
|                     .padding(.top, 13.3) | ||||
|                      | ||||
|                     ScrollView(showsIndicators: false) { | ||||
|                         LazyVGrid(columns: columns, spacing: 0) { | ||||
|                             ForEach(0..<donationStatus.donationList.count, id: \.self) { index in | ||||
|                                 let item = donationStatus.donationList[index] | ||||
|                                 LiveRoomDonationRankingItemView( | ||||
|                                     index: index, | ||||
|                                     item: item, | ||||
|                                     itemCount: donationStatus.donationList.count | ||||
|                                 ) | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                     .padding(.top, 8) | ||||
|                 } | ||||
|             } | ||||
|             .padding(20) | ||||
|             .background(Color(hex: "222222")) | ||||
|             .cornerRadius(8) | ||||
|              | ||||
|             if viewModel.isLoading { | ||||
|                 LoadingView() | ||||
|             } | ||||
|         } | ||||
|         .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) { | ||||
|             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(.leading) | ||||
|                     .cornerRadius(20) | ||||
|                     .padding(.bottom, 66.7) | ||||
|                 Spacer() | ||||
|             } | ||||
|         } | ||||
|         .onAppear { | ||||
|             viewModel.getDonationStatus() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,114 @@ | ||||
| // | ||||
| //  LiveRoomDonationRankingItemView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/15. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct LiveRoomDonationRankingItemView: View { | ||||
|      | ||||
|     let index: Int | ||||
|     let item: GetLiveRoomDonationItem | ||||
|     let itemCount: Int | ||||
|      | ||||
|     let rankingCrawns = ["ic_crown_1", "ic_crown_2", "ic_crown_3"] | ||||
|     let rankingColors = [ | ||||
|         [Color(hex: "ffdc00"), Color(hex: "ffb600")], | ||||
|         [Color(hex: "ffffff"), Color(hex: "9f9f9f")], | ||||
|         [Color(hex: "e6a77a"), Color(hex: "c67e4a")], | ||||
|         [Color(hex: "ffffff").opacity(0), Color(hex: "ffffff").opacity(0)] | ||||
|     ] | ||||
|      | ||||
|     var body: some View { | ||||
|         HStack(spacing: 0) { | ||||
|             ZStack { | ||||
|                 KFImage(URL(string: item.profileImage)) | ||||
|                     .cancelOnDisappear(true) | ||||
|                     .downsampling(size: CGSize(width: 60, height: 60)) | ||||
|                     .resizable() | ||||
|                     .scaledToFill() | ||||
|                     .frame(width: 60, height: 60, alignment: .top) | ||||
|                     .clipShape(Circle()) | ||||
|                     .overlay( | ||||
|                         Circle() | ||||
|                             .stroke( | ||||
|                                 AngularGradient(colors: rankingColors[index < 4 ? index : 3], center: .center), | ||||
|                                 lineWidth: 3 | ||||
|                             ) | ||||
|                     ) | ||||
|                  | ||||
|                 if index < 3 { | ||||
|                     VStack(alignment: .trailing, spacing: 0) { | ||||
|                         Spacer() | ||||
|                          | ||||
|                         Image(rankingCrawns[index]) | ||||
|                             .resizable() | ||||
|                             .frame(width: 25, height: 25) | ||||
|                     } | ||||
|                     .frame(width: 63, height: 63, alignment: .trailing) | ||||
|                 } | ||||
|             } | ||||
|             .frame(width: 63, height: 63) | ||||
|              | ||||
|             Text("\(index + 1)") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 13.3)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .padding(.leading, 20) | ||||
|                 .padding(.trailing, 13.3) | ||||
|              | ||||
|             let nickname = item.nickname.count > 10 ? "\(String(item.nickname.prefix(10)))..." : item.nickname | ||||
|             Text(nickname) | ||||
|                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|              | ||||
|             Spacer() | ||||
|              | ||||
|             if item.can > 0 { | ||||
|                 Text("\(item.can) 코인") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|             } | ||||
|         } | ||||
|         .padding(.horizontal, isTop3Index(index: index) ? 20 : 0) | ||||
|         .padding(.top, getTopPadding(index: index)) | ||||
|         .padding(.bottom, getBottomPadding(index: index)) | ||||
|         .background(Color(hex: "2b2635").opacity(isTop3Index(index: index) ? 1 : 0)) | ||||
|         .cornerRadius(4.7, corners: cornerRadiusConers(index: index)) | ||||
|         .padding(.horizontal, isTop3Index(index: index) ? 0 : 6.7) | ||||
|     } | ||||
|      | ||||
|     private func isTop3Index(index: Int) -> Bool { | ||||
|         return index == 0 || index == 1 || index == 2 | ||||
|     } | ||||
|      | ||||
|     private func getTopPadding(index: Int) -> CGFloat { | ||||
|         if index == 0 || index == 3 { | ||||
|             return 20 | ||||
|         } else { | ||||
|             return 6.7 | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func getBottomPadding(index: Int) -> CGFloat { | ||||
|         if (index == 0 && itemCount == 1) || (index == 1 && itemCount == 2) || index == 2 { | ||||
|             return 20 | ||||
|         } else { | ||||
|             return 6.7 | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func cornerRadiusConers(index: Int) -> UIRectCorner { | ||||
|         if (index == 0 && itemCount == 1) { | ||||
|             return [.topLeft, .topRight, .bottomLeft, .bottomRight] | ||||
|         } else if index == 0 { | ||||
|             return [.topLeft, .topRight] | ||||
|         } else if (index == 1 && itemCount == 2) || index == 2 { | ||||
|             return [.bottomLeft, .bottomRight] | ||||
|         } else { | ||||
|             return [] | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,36 @@ | ||||
| // | ||||
| //  LiveRoomDonationRankingTotalCanView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/15. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct LiveRoomDonationRankingTotalCanView: View { | ||||
|      | ||||
|     let totalCan: Int | ||||
|      | ||||
|     var body: some View { | ||||
|         HStack(alignment: .center, spacing: 0) { | ||||
|             Text("합계") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 13.3)) | ||||
|                 .foregroundColor(Color(hex: "d2d2d2")) | ||||
|              | ||||
|             Spacer() | ||||
|              | ||||
|             Text("\(totalCan)") | ||||
|                 .font(.custom(Font.medium.rawValue, size: 16)) | ||||
|                 .foregroundColor(Color(hex: "9970ff")) | ||||
|              | ||||
|             Text("캔") | ||||
|                 .font(.custom(Font.medium.rawValue, size: 10.7)) | ||||
|                 .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                 .padding(.leading, 4) | ||||
|         } | ||||
|         .padding(.horizontal, 18.7) | ||||
|         .padding(.vertical, 10.7) | ||||
|         .background(Color(hex: "2b2635")) | ||||
|         .cornerRadius(8) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										238
									
								
								SodaLive/Sources/Live/Room/Dialog/LiveRoomInfoEditDialog.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,238 @@ | ||||
| // | ||||
| //  LiveRoomInfoEditDialog.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/15. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct LiveRoomInfoEditDialog: View { | ||||
|      | ||||
|     @Binding var isShowing: Bool | ||||
|     @Binding var isShowPhotoPicker: Bool | ||||
|      | ||||
|     @State private var title = "" | ||||
|     @State private var notice = "" | ||||
|      | ||||
|     let placeholder = "라이브 공지를 입력하세요" | ||||
|      | ||||
|     let viewModel: LiveRoomViewModel | ||||
|      | ||||
|     let isLoading: Bool | ||||
|     let coverImageUrl: String? | ||||
|     let coverImage: UIImage? | ||||
|     var confirmAction: (String, String) -> Void | ||||
|      | ||||
|     init( | ||||
|         isShowing: Binding<Bool>, | ||||
|         isShowPhotoPicker: Binding<Bool>, | ||||
|         viewModel: LiveRoomViewModel, | ||||
|         isLoading: Bool, | ||||
|         currentTitle: String, | ||||
|         currentNotice: String, | ||||
|         coverImageUrl: String, | ||||
|         coverImage: UIImage?, | ||||
|         confirmAction: @escaping (String, String) -> Void | ||||
|     ) { | ||||
|         self._isShowing = isShowing | ||||
|         self._isShowPhotoPicker = isShowPhotoPicker | ||||
|          | ||||
|         self.viewModel = viewModel | ||||
|         self.isLoading = isLoading | ||||
|          | ||||
|         self.title = currentTitle | ||||
|         self.notice = currentNotice | ||||
|         self.coverImageUrl = coverImageUrl | ||||
|         self.coverImage = coverImage | ||||
|         self.confirmAction = confirmAction | ||||
|          | ||||
|         UITextView.appearance().backgroundColor = .clear | ||||
|     } | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             GeometryReader { proxy in | ||||
|                 ScrollView(.vertical, showsIndicators: false) { | ||||
|                     VStack(alignment: .leading, spacing: 0) { | ||||
|                         HStack(spacing: 0) { | ||||
|                             Text("라이브 수정") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                              | ||||
|                             Spacer() | ||||
|                              | ||||
|                             Image("ic_close_white") | ||||
|                                 .onTapGesture { | ||||
|                                     isShowing = false | ||||
|                                 } | ||||
|                         } | ||||
|                          | ||||
|                         HStack { | ||||
|                             Spacer() | ||||
|                              | ||||
|                             ZStack { | ||||
|                                 if let coverImage = coverImage { | ||||
|                                     Image(uiImage: coverImage) | ||||
|                                         .resizable() | ||||
|                                         .scaledToFill() | ||||
|                                         .frame(width: 80, height: 116.8, alignment: .top) | ||||
|                                         .clipped() | ||||
|                                         .cornerRadius(10) | ||||
|                                 } else if let coverImageUrl = coverImageUrl { | ||||
|                                     KFImage(URL(string: coverImageUrl)) | ||||
|                                         .resizable() | ||||
|                                         .scaledToFill() | ||||
|                                         .frame(width: 80, height: 116.8, alignment: .top) | ||||
|                                         .clipped() | ||||
|                                         .cornerRadius(10) | ||||
|                                 } else { | ||||
|                                     Image("ic_logo_220") | ||||
|                                         .resizable() | ||||
|                                         .scaledToFit() | ||||
|                                         .frame(width: 80, height: 116.8) | ||||
|                                         .background(Color(hex: "3e3358")) | ||||
|                                         .cornerRadius(10) | ||||
|                                 } | ||||
|                                  | ||||
|                                 Image("ic_camera") | ||||
|                                     .padding(10) | ||||
|                                     .background(Color(hex: "9970ff")) | ||||
|                                     .cornerRadius(30) | ||||
|                                     .offset(x: 40, y: 40) | ||||
|                             } | ||||
|                             .frame(alignment: .bottomTrailing) | ||||
|                             .padding(.top, 13.3) | ||||
|                             .onTapGesture { | ||||
|                                 isShowPhotoPicker = true | ||||
|                             } | ||||
|                              | ||||
|                             Spacer() | ||||
|                         } | ||||
|                          | ||||
|                         TitleInputView() | ||||
|                             .padding(.top, 40) | ||||
|                          | ||||
|                         ContentInputView() | ||||
|                             .padding(.top, 33.3) | ||||
|                          | ||||
|                         HStack(spacing: 13.3) { | ||||
|                             Text("취소") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                                 .foregroundColor(Color(hex: "9970ff")) | ||||
|                                 .padding(.vertical, 16) | ||||
|                                 .frame(width: (screenSize().width - 40) / 2) | ||||
|                                 .background(Color(hex: "9970ff").opacity(0.2)) | ||||
|                                 .cornerRadius(10) | ||||
|                                 .overlay( | ||||
|                                     RoundedRectangle(cornerRadius: 10) | ||||
|                                         .strokeBorder(lineWidth: 1) | ||||
|                                         .foregroundColor(Color(hex: "9970ff")) | ||||
|                                 ) | ||||
|                                 .onTapGesture { | ||||
|                                     isShowing = false | ||||
|                                 } | ||||
|                              | ||||
|                             Text("수정하기") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                                 .foregroundColor(.white) | ||||
|                                 .padding(.vertical, 16) | ||||
|                                 .frame(width: (screenSize().width - 40) / 2) | ||||
|                                 .background(Color(hex: "9970ff")) | ||||
|                                 .cornerRadius(10) | ||||
|                                 .onTapGesture { | ||||
|                                     confirmAction( | ||||
|                                         title, | ||||
|                                         notice.trimmingCharacters(in: .whitespacesAndNewlines) != placeholder ? notice : "" | ||||
|                                     ) | ||||
|                                     isShowing = false | ||||
|                                 } | ||||
|                         } | ||||
|                         .padding(.top, 45) | ||||
|                          | ||||
|                         Spacer() | ||||
|                     } | ||||
|                     .padding(13.3) | ||||
|                     .frame(width: proxy.size.width, height: proxy.size.height) | ||||
|                     .onTapGesture { hideKeyboard() } | ||||
|                 } | ||||
|                 .background(Color(hex: "222222").edgesIgnoringSafeArea(.all)) | ||||
|             } | ||||
|              | ||||
|             if viewModel.isShowPopup { | ||||
|                 LiveRoomDialogView( | ||||
|                     content: viewModel.errorMessage, | ||||
|                     cancelTitle: viewModel.popupCancelTitle, | ||||
|                     cancelAction: viewModel.popupCancelAction, | ||||
|                     confirmTitle: viewModel.popupConfirmTitle, | ||||
|                     confirmAction: viewModel.popupConfirmAction | ||||
|                 ).onAppear { | ||||
|                     if viewModel.popupConfirmTitle == nil && viewModel.popupConfirmAction == nil { | ||||
|                         DispatchQueue.main.asyncAfter(deadline: .now() + 2) { | ||||
|                             viewModel.isShowPopup = false | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             if isLoading { | ||||
|                 LoadingView() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func TitleInputView() -> some View { | ||||
|         VStack(alignment: .leading, spacing: 0) { | ||||
|             Text("제목") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|              | ||||
|             TextField("라이브 제목을 입력하세요", text: $title) | ||||
|                 .autocapitalization(.none) | ||||
|                 .disableAutocorrection(true) | ||||
|                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                 .accentColor(Color(hex: "9970ff")) | ||||
|                 .keyboardType(.default) | ||||
|                 .padding(.top, 12) | ||||
|                 .padding(.horizontal, 6.7) | ||||
|              | ||||
|             Rectangle() | ||||
|                 .frame(height: 1) | ||||
|                 .foregroundColor(Color(hex: "909090").opacity(0.7)) | ||||
|                 .padding(.top, 8.3) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func ContentInputView() -> some View { | ||||
|         VStack(spacing: 13.3) { | ||||
|             HStack(spacing: 0) { | ||||
|                 Text("공지") | ||||
|                     .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                  | ||||
|                 Spacer() | ||||
|                  | ||||
|                 Text("\(notice.count)자") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color(hex: "ff5c49")) + | ||||
|                 Text(" / 1000자") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color(hex: "777777")) | ||||
|             } | ||||
|              | ||||
|             TextViewWrapper( | ||||
|                 text: $notice, | ||||
|                 placeholder: placeholder, | ||||
|                 textColorHex: "eeeeee", | ||||
|                 backgroundColorHex: "303030" | ||||
|             ) | ||||
|             .frame(width: screenSize().width - 26.7, height: 133.3) | ||||
|             .cornerRadius(6.7) | ||||
|             .padding(.top, 13.3) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,86 @@ | ||||
| // | ||||
| //  LiveRoomProfileDialog.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/15. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct LiveRoomProfileDialog: View { | ||||
|      | ||||
|     @Binding var isShowing: Bool | ||||
|     let profileInfo: LiveRoomMember | ||||
|     let creatorId: Int | ||||
|     let isSpeaker: Bool | ||||
|      | ||||
|     let memberId = UserDefaults.int(forKey: .userId) | ||||
|      | ||||
|     var onClickInviteSpeaker: ((Int) -> Void)? = nil | ||||
|     var onClickChangeListener: ((Int) -> Void)? = nil | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             Color.black.opacity(0.7).ignoresSafeArea() | ||||
|                 .onTapGesture { | ||||
|                     isShowing = false | ||||
|                 } | ||||
|              | ||||
|             HStack(spacing: 13.3) { | ||||
|                 KFImage(URL(string: profileInfo.profileImage)) | ||||
|                     .resizable() | ||||
|                     .frame(width: 80, height: 116.7, alignment: .top) | ||||
|                     .clipped() | ||||
|                     .cornerRadius(13.3) | ||||
|                  | ||||
|                 VStack(alignment: .leading, spacing: 0) { | ||||
|                     Text(profileInfo.nickname) | ||||
|                         .font(.custom(Font.bold.rawValue, size: 20)) | ||||
|                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                         .padding(.top, 6.7) | ||||
|                      | ||||
|                     Spacer() | ||||
|                      | ||||
|                     if isSpeaker { | ||||
|                         if profileInfo.role == .LISTENER, let onClickInviteSpeaker = onClickInviteSpeaker { | ||||
|                             Text("스피커로 초대") | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                                 .foregroundColor(Color(hex: "9970ff")) | ||||
|                                 .padding(.horizontal, 15.4) | ||||
|                                 .padding(.vertical, 8.3) | ||||
|                                 .background(Color.white) | ||||
|                                 .cornerRadius(13.3) | ||||
|                                 .onTapGesture { | ||||
|                                     onClickInviteSpeaker(profileInfo.id) | ||||
|                                     isShowing = false | ||||
|                                 } | ||||
|                         } | ||||
|                          | ||||
|                         if (memberId == creatorId || memberId == profileInfo.id) && profileInfo.id != creatorId && profileInfo.role == .SPEAKER, | ||||
|                             let onClickChangeListener = onClickChangeListener { | ||||
|                             Text("리스너로 변경") | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                                 .foregroundColor(Color(hex: "9970ff")) | ||||
|                                 .padding(.horizontal, 15.4) | ||||
|                                 .padding(.vertical, 8.3) | ||||
|                                 .background(Color.white) | ||||
|                                 .cornerRadius(13.3) | ||||
|                                 .onTapGesture { | ||||
|                                     onClickChangeListener(profileInfo.id) | ||||
|                                     isShowing = false | ||||
|                                 } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 .frame(height: 116.7) | ||||
|                  | ||||
|                 Spacer() | ||||
|             } | ||||
|             .padding(20) | ||||
|             .frame(width: screenSize().width - 53.4) | ||||
|             .background(Color(hex: "9970ff")) | ||||
|             .cornerRadius(16.7) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,189 @@ | ||||
| // | ||||
| //  LiveRoomProfileItemTitleView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct LiveRoomProfileItemTitleView: View { | ||||
|      | ||||
|     let title: String | ||||
|     let count: Int? | ||||
|     let totalCount: Int? | ||||
|      | ||||
|     var body: some View { | ||||
|         HStack(spacing: 0) { | ||||
|             Text(title) | ||||
|                 .font(.custom(Font.bold.rawValue, size: 13)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|              | ||||
|             if let count = count { | ||||
|                 Text("\(count)") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13)) | ||||
|                     .foregroundColor(Color(hex: "9970ff")) | ||||
|                     .padding(.leading, 6.7) | ||||
|             } | ||||
|              | ||||
|             if let totalCount = totalCount { | ||||
|                 Text("/\(totalCount > 9 ? 9 : totalCount - 1)") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13)) | ||||
|                     .foregroundColor(Color(hex: "bbbbbb")) | ||||
|             } | ||||
|              | ||||
|             Spacer() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct LiveRoomProfileItemMasterView: View { | ||||
|      | ||||
|     let id: Int | ||||
|     let nickname: String | ||||
|     let profileUrl: String | ||||
|     let onClickProfile: (Int) -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading, spacing: 20) { | ||||
|             HStack(spacing: 0) { | ||||
|                 KFImage(URL(string: profileUrl)) | ||||
|                     .resizable() | ||||
|                     .frame(width: 60, height: 60) | ||||
|                     .clipShape(Circle()) | ||||
|                     .onTapGesture { onClickProfile(id) } | ||||
|                  | ||||
|                 Image("ic_crown") | ||||
|                     .padding(.leading, 16.7) | ||||
|                  | ||||
|                 Text(nickname) | ||||
|                     .font(.custom(Font.medium.rawValue, size: 14)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                     .padding(.leading, 4) | ||||
|             } | ||||
|             .padding(.horizontal, 16.7) | ||||
|              | ||||
|             Rectangle() | ||||
|                 .frame(height: 1) | ||||
|                 .foregroundColor(Color(hex: "909090").opacity(0.3)) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct LiveRoomProfileItemUserView: View { | ||||
|     let isStaff: Bool | ||||
|     let userId: Int | ||||
|     let nickname: String | ||||
|     let profileUrl: String | ||||
|     let role: LiveRoomMemberRole | ||||
|      | ||||
|     let onClickChangeListener: (Int) -> Void | ||||
|     let onClickInviteSpeaker: (Int) -> Void | ||||
|     let onClickKickOut: (Int) -> Void | ||||
|     let onClickProfile: (Int) -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             VStack(spacing: 10) { | ||||
|                 HStack(spacing: 0) { | ||||
|                     KFImage(URL(string: profileUrl)) | ||||
|                         .resizable() | ||||
|                         .frame(width: 46.7, height: 46.7) | ||||
|                         .clipShape(Circle()) | ||||
|                         .onTapGesture { onClickProfile(userId) } | ||||
|                      | ||||
|                     if role == .MANAGER { | ||||
|                         Image("ic_badge_manager") | ||||
|                             .padding(.leading, 16.7) | ||||
|                          | ||||
|                         Text(nickname) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14)) | ||||
|                             .foregroundColor(Color(hex: "eeeeee")) | ||||
|                             .lineLimit(2) | ||||
|                             .multilineTextAlignment(.leading) | ||||
|                             .padding(.leading, 4) | ||||
|                             .padding(.trailing, 10) | ||||
|                     } else { | ||||
|                         Text(nickname) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14)) | ||||
|                             .foregroundColor(Color(hex: "eeeeee")) | ||||
|                             .lineLimit(2) | ||||
|                             .multilineTextAlignment(.leading) | ||||
|                             .padding(.horizontal, 10) | ||||
|                     } | ||||
|                      | ||||
|                     Spacer() | ||||
|                      | ||||
|                     if role == .LISTENER && isStaff { | ||||
|                         Text("스피커로 초대") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                             .foregroundColor(Color(hex: "ffffff")) | ||||
|                             .padding(.horizontal, 5.5) | ||||
|                             .padding(.vertical, 12) | ||||
|                             .background(Color(hex: "9970ff").opacity(0.3)) | ||||
|                             .cornerRadius(6.7) | ||||
|                             .overlay( | ||||
|                                 RoundedRectangle(cornerRadius: 6.7) | ||||
|                                     .stroke(Color(hex: "9970ff"), lineWidth: 1) | ||||
|                             ) | ||||
|                             .onTapGesture { | ||||
|                                 onClickInviteSpeaker(userId) | ||||
|                             } | ||||
|                     } | ||||
|                      | ||||
|                     if role == .SPEAKER && (userId == UserDefaults.int(forKey: .userId) || isStaff) { | ||||
|                         Text("리스너로 변경") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                             .foregroundColor(Color(hex: "ffffff")) | ||||
|                             .padding(.horizontal, 5.5) | ||||
|                             .padding(.vertical, 12) | ||||
|                             .background(Color(hex: "9970ff")) | ||||
|                             .cornerRadius(6.7) | ||||
|                             .onTapGesture { | ||||
|                                 onClickChangeListener(userId) | ||||
|                             } | ||||
|                     } | ||||
|                      | ||||
|                     if role != .MANAGER && isStaff { | ||||
|                         Image("ic_kick_out") | ||||
|                             .padding(.leading, 10) | ||||
|                             .onTapGesture { | ||||
|                                 onClickKickOut(userId) | ||||
|                             } | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 Rectangle() | ||||
|                     .frame(height: 1) | ||||
|                     .foregroundColor(Color(hex: "909090").opacity(0.3)) | ||||
|             } | ||||
|             .padding(.horizontal, 16.7) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct LiveRoomProfileRequestSpeakerView: View { | ||||
|      | ||||
|     let onClickRequestSpeaker: () -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         HStack(spacing: 6.7) { | ||||
|             Spacer() | ||||
|             Image("ic_request_speak") | ||||
|             Text("스피커 요청하기") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 13.3)) | ||||
|                 .foregroundColor(.white) | ||||
|             Spacer() | ||||
|         } | ||||
|         .padding(.vertical, 8) | ||||
|         .overlay( | ||||
|             RoundedRectangle(cornerRadius: 5.3) | ||||
|                 .stroke(Color(hex: "909090"), lineWidth: 1) | ||||
|         ) | ||||
|         .onTapGesture { | ||||
|             onClickRequestSpeaker() | ||||
|         } | ||||
|         .padding(.horizontal, 16.7) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,249 @@ | ||||
| // | ||||
| //  LiveRoomProfilesDialogView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct LiveRoomProfilesDialogView: View { | ||||
|      | ||||
|     @Binding var isShowing: Bool | ||||
|      | ||||
|     let viewModel: LiveRoomViewModel | ||||
|     let roomInfo: GetRoomInfoResponse | ||||
|      | ||||
|     var profiles: [AnyView] = [] | ||||
|     let accountId = UserDefaults.int(forKey: .userId) | ||||
|      | ||||
|     init( | ||||
|         isShowing: Binding<Bool>, | ||||
|         viewModel: LiveRoomViewModel, | ||||
|         roomInfo: GetRoomInfoResponse, | ||||
|         isShowRequestSpeaker: Bool, | ||||
|         onClickRequestSpeaker: @escaping () -> Void, | ||||
|         registerNotification: @escaping () -> Void, | ||||
|         unRegisterNotification: @escaping () -> Void, | ||||
|         onClickProfile: @escaping (Int) -> Void | ||||
|     ) { | ||||
|         self._isShowing = isShowing | ||||
|         self.viewModel = viewModel | ||||
|         self.roomInfo = roomInfo | ||||
|          | ||||
|         self.profiles.append( | ||||
|             AnyView( | ||||
|                 LiveRoomProfileItemTitleView( | ||||
|                     title: "스탭", | ||||
|                     count: roomInfo.managerList.count, | ||||
|                     totalCount: nil | ||||
|                 ) | ||||
|                 .padding(.vertical, 14) | ||||
|             ) | ||||
|         ) | ||||
|          | ||||
|         let isStaff = viewModel.isEqualToStaffId(creatorId: UserDefaults.int(forKey: .userId)) || | ||||
|         roomInfo.creatorId == UserDefaults.int(forKey: .userId) | ||||
|          | ||||
|         for manager in roomInfo.managerList { | ||||
|             self.profiles.append( | ||||
|                 AnyView( | ||||
|                     LiveRoomProfileItemUserView( | ||||
|                         isStaff: isStaff , | ||||
|                         userId: manager.id, | ||||
|                         nickname: manager.nickname, | ||||
|                         profileUrl: manager.profileImage, | ||||
|                         role: manager.role, | ||||
|                         onClickChangeListener: { _ in }, | ||||
|                         onClickInviteSpeaker: { _ in }, | ||||
|                         onClickKickOut: { _ in }, | ||||
|                         onClickProfile: onClickProfile | ||||
|                     ) | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|          | ||||
|         self.profiles.append( | ||||
|             AnyView( | ||||
|                 LiveRoomProfileItemTitleView( | ||||
|                     title: "스피커", | ||||
|                     count: roomInfo.speakerList.count - 1, | ||||
|                     totalCount: roomInfo.totalAvailableParticipantsCount | ||||
|                 ) | ||||
|                 .padding(.vertical, 14) | ||||
|             ) | ||||
|         ) | ||||
|          | ||||
|         for speaker in roomInfo.speakerList { | ||||
|             if speaker.id == roomInfo.creatorId { | ||||
|                 self.profiles.insert( | ||||
|                     AnyView( | ||||
|                         LiveRoomProfileItemMasterView( | ||||
|                             id: speaker.id, | ||||
|                             nickname: speaker.nickname, | ||||
|                             profileUrl: speaker.profileImage, | ||||
|                             onClickProfile: onClickProfile | ||||
|                         ) | ||||
|                     ), | ||||
|                     at: 0 | ||||
|                 ) | ||||
|             } else { | ||||
|                 self.profiles.append( | ||||
|                     AnyView( | ||||
|                         LiveRoomProfileItemUserView( | ||||
|                             isStaff: isStaff, | ||||
|                             userId: speaker.id, | ||||
|                             nickname: speaker.nickname, | ||||
|                             profileUrl: speaker.profileImage, | ||||
|                             role: speaker.role, | ||||
|                             onClickChangeListener: { | ||||
|                                 if $0 == UserDefaults.int(forKey: .userId) { | ||||
|                                     viewModel.setListener() | ||||
|                                     return | ||||
|                                 } | ||||
|                                  | ||||
|                                 viewModel.changeListener(peerId: $0) | ||||
|                             }, | ||||
|                             onClickInviteSpeaker: { _ in }, | ||||
|                             onClickKickOut: { | ||||
|                                 viewModel.kickOutId = $0 | ||||
|                                 viewModel.isShowKickOutPopup = true | ||||
|                             }, | ||||
|                             onClickProfile: onClickProfile | ||||
|                         ) | ||||
|                     ) | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         if isShowRequestSpeaker { | ||||
|             self.profiles.append( | ||||
|                 AnyView( | ||||
|                     LiveRoomProfileRequestSpeakerView { | ||||
|                         onClickRequestSpeaker() | ||||
|                     } | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|          | ||||
|         self.profiles.append( | ||||
|             AnyView( | ||||
|                 LiveRoomProfileItemTitleView( | ||||
|                     title: "리스너", | ||||
|                     count: nil, | ||||
|                     totalCount: nil | ||||
|                 ) | ||||
|                 .padding(.top, 20) | ||||
|                 .padding(.bottom, 14) | ||||
|             ) | ||||
|         ) | ||||
|          | ||||
|         for listener in roomInfo.listenerList { | ||||
|             self.profiles.append( | ||||
|                 AnyView( | ||||
|                     LiveRoomProfileItemUserView( | ||||
|                         isStaff: isStaff, | ||||
|                         userId: listener.id, | ||||
|                         nickname: listener.nickname, | ||||
|                         profileUrl: listener.profileImage, | ||||
|                         role: listener.role, | ||||
|                         onClickChangeListener: { _ in }, | ||||
|                         onClickInviteSpeaker: { | ||||
|                             if viewModel.liveRoomInfo!.speakerList.count <= 9 { | ||||
|                                 viewModel.inviteSpeaker(peerId: $0) | ||||
|                                 viewModel.popupContent = "스피커 요청을 보냈습니다.\n잠시만 기다려 주세요." | ||||
|                                 viewModel.isShowPopup = true | ||||
|                             } else { | ||||
|                                 viewModel.errorMessage = "스피커 정원을 초과했습니다." | ||||
|                                 viewModel.isShowErrorPopup = true | ||||
|                             } | ||||
|                         }, | ||||
|                         onClickKickOut: { | ||||
|                             viewModel.kickOutId = $0 | ||||
|                             viewModel.isShowKickOutPopup = true | ||||
|                         }, | ||||
|                         onClickProfile: onClickProfile | ||||
|                     ) | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             VStack(spacing: 16.7) { | ||||
|                 HStack(spacing: 0) { | ||||
|                     Text("참여자") | ||||
|                         .font(.custom(Font.bold.rawValue, size: 15)) | ||||
|                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                      | ||||
|                     Text("\(roomInfo.participantsCount)") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 14)) | ||||
|                         .foregroundColor(Color(hex: "9970ff")) | ||||
|                         .padding(.leading, 6.7) | ||||
|                      | ||||
|                     Text("/\(roomInfo.totalAvailableParticipantsCount)") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 14)) | ||||
|                         .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                      | ||||
|                     Spacer() | ||||
|                      | ||||
|                     Image("ic_close_white") | ||||
|                         .resizable() | ||||
|                         .frame(width: 20, height: 20) | ||||
|                         .onTapGesture { isShowing = false } | ||||
|                 } | ||||
|                  | ||||
|                 ScrollView(.vertical, showsIndicators: false) { | ||||
|                     VStack(alignment: .leading, spacing: 20) { | ||||
|                         ForEach(0..<profiles.count, id: \.self) { index in | ||||
|                             profiles[index] | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .padding(.vertical, 26.7) | ||||
|             .padding(.horizontal, 13.3) | ||||
|             .background(Color(hex: "222222").edgesIgnoringSafeArea(.all)) | ||||
|             .cornerRadius(16.7) | ||||
|              | ||||
|             if viewModel.isShowPopup { | ||||
|                 LiveRoomDialogView( | ||||
|                     content: viewModel.popupContent, | ||||
|                     cancelTitle: viewModel.popupCancelTitle, | ||||
|                     cancelAction: viewModel.popupCancelAction, | ||||
|                     confirmTitle: viewModel.popupConfirmTitle, | ||||
|                     confirmAction: viewModel.popupConfirmAction | ||||
|                 ).onAppear { | ||||
|                     if viewModel.popupConfirmTitle == nil && viewModel.popupConfirmAction == nil { | ||||
|                         DispatchQueue.main.asyncAfter(deadline: .now() + 2) { | ||||
|                             viewModel.isShowPopup = false | ||||
|                             viewModel.popupCancelTitle = nil | ||||
|                             viewModel.popupCancelAction = nil | ||||
|                             viewModel.popupConfirmTitle = nil | ||||
|                             viewModel.popupConfirmAction = nil | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             if viewModel.isShowKickOutPopup { | ||||
|                 SodaDialog( | ||||
|                     title: "내보내기", | ||||
|                     desc: viewModel.kickOutDesc, | ||||
|                     confirmButtonTitle: "내보내기", | ||||
|                     confirmButtonAction: { | ||||
|                         viewModel.kickOut() | ||||
|                     }, | ||||
|                     cancelButtonTitle: "취소", | ||||
|                     cancelButtonAction: { | ||||
|                         viewModel.isShowKickOutPopup = false | ||||
|                         viewModel.kickOutDesc = "" | ||||
|                         viewModel.kickOutId = 0 | ||||
|                     } | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,259 @@ | ||||
| // | ||||
| //  LiveRoomUserProfileDialogView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/15. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct LiveRoomUserProfileDialogView: View { | ||||
|      | ||||
|     @Binding var isShowing: Bool | ||||
|      | ||||
|     @State private var introduceLineLimit: Int? = 2 | ||||
|      | ||||
|     let viewModel: LiveRoomViewModel | ||||
|     let userProfile: GetLiveRoomUserProfileResponse | ||||
|     let onClickSetManager: (Int) -> Void | ||||
|     let onClickReleaseManager: (Int) -> Void | ||||
|     let onClickFollow: (Int) -> Void | ||||
|     let onClickUnFollow: (Int) -> Void | ||||
|     let onClickInviteSpeaker: (Int) -> Void | ||||
|     let onClickChangeListener: (Int) -> Void | ||||
|     let onClickMenu: (Int, String, Bool) -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             VStack(spacing: 0) { | ||||
|                 HStack(spacing: 0) { | ||||
|                     Text("프로필") | ||||
|                         .font(.custom(Font.bold.rawValue, size: 15)) | ||||
|                         .foregroundColor(Color(hex: "eeeeee")) | ||||
|                      | ||||
|                     Spacer() | ||||
|                      | ||||
|                     Image("ic_close_white") | ||||
|                         .onTapGesture { | ||||
|                             isShowing = false | ||||
|                         } | ||||
|                 } | ||||
|                 .padding(.top, 13.3) | ||||
|                  | ||||
|                 ScrollView(.vertical, showsIndicators: false) { | ||||
|                     VStack(alignment: .leading, spacing: 0) { | ||||
|                         HStack(spacing: 8) { | ||||
|                             Text(userProfile.nickname) | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|                              | ||||
|                             Text(userProfile.gender) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 11.3)) | ||||
|                                 .foregroundColor(Color(hex: "ffffff")) | ||||
|                                 .padding(.horizontal, 5.3) | ||||
|                                 .padding(.vertical, 3) | ||||
|                                 .background(Color(hex: "555555")) | ||||
|                                 .cornerRadius(23.3) | ||||
|                              | ||||
|                             Spacer() | ||||
|                              | ||||
|                             Image("ic_seemore_vertical") | ||||
|                                 .onTapGesture { | ||||
|                                     onClickMenu( | ||||
|                                         userProfile.userId, | ||||
|                                         userProfile.nickname, | ||||
|                                         userProfile.isBlock | ||||
|                                     ) | ||||
|                                 } | ||||
|                         } | ||||
|                         .padding(.top, 21.3) | ||||
|                          | ||||
|                         HStack(spacing: 8) { | ||||
|                             if let isFollwing = userProfile.isFollowing { | ||||
|                                 if isFollwing { | ||||
|                                     HStack(spacing: 4) { | ||||
|                                         Image("ic_alarm_selected") | ||||
|                                             .resizable() | ||||
|                                             .frame(width: 18.7, height: 18.7) | ||||
|                                          | ||||
|                                         Text("팔로잉") | ||||
|                                             .font(.custom(Font.bold.rawValue, size: 12)) | ||||
|                                             .foregroundColor(Color(hex: "ffffff")) | ||||
|                                     } | ||||
|                                     .padding(.vertical, 7.3) | ||||
|                                     .frame(maxWidth: .infinity) | ||||
|                                     .background(Color(hex: "3e1b93")) | ||||
|                                     .cornerRadius(16.7) | ||||
|                                     .overlay( | ||||
|                                         RoundedRectangle(cornerRadius: 16.7) | ||||
|                                             .strokeBorder(lineWidth: 1) | ||||
|                                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                                     ) | ||||
|                                     .onTapGesture { onClickUnFollow(userProfile.userId) } | ||||
|                                 } else { | ||||
|                                     HStack(spacing: 4) { | ||||
|                                         Image("ic_alarm") | ||||
|                                             .resizable() | ||||
|                                             .frame(width: 18.7, height: 18.7) | ||||
|                                          | ||||
|                                         Text("팔로우") | ||||
|                                             .font(.custom(Font.bold.rawValue, size: 12)) | ||||
|                                             .foregroundColor(Color(hex: "ffffff")) | ||||
|                                     } | ||||
|                                     .padding(.vertical, 7.3) | ||||
|                                     .frame(maxWidth: .infinity) | ||||
|                                     .cornerRadius(16.7) | ||||
|                                     .overlay( | ||||
|                                         RoundedRectangle(cornerRadius: 16.7) | ||||
|                                             .strokeBorder(lineWidth: 1) | ||||
|                                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                                     ) | ||||
|                                     .onTapGesture { onClickFollow(userProfile.userId) } | ||||
|                                 } | ||||
|                             } | ||||
|                              | ||||
|                             HStack(spacing: 4) { | ||||
|                                 Image("ic_message_send") | ||||
|                                     .resizable() | ||||
|                                     .frame(width: 18.7, height: 18.7) | ||||
|                                  | ||||
|                                 Text("메시지 보내기") | ||||
|                                     .font(.custom(Font.bold.rawValue, size: 12)) | ||||
|                                     .foregroundColor(Color(hex: "ffffff")) | ||||
|                             } | ||||
|                             .padding(.vertical, 7.3) | ||||
|                             .frame(maxWidth: .infinity) | ||||
|                             .background(Color(hex: "9970ff")) | ||||
|                             .cornerRadius(16.7) | ||||
|                             .onTapGesture { | ||||
|                                 AppState.shared.setAppStep(step: .writeTextMessage( | ||||
|                                     userId: userProfile.userId, | ||||
|                                     nickname: userProfile.nickname)) | ||||
|                             } | ||||
|                         } | ||||
|                         .fixedSize(horizontal: false, vertical: true) | ||||
|                         .padding(.top, 21.3) | ||||
|                          | ||||
|                         KFImage(URL(string: userProfile.profileUrl)) | ||||
|                             .resizable() | ||||
|                             .aspectRatio(CGSize(width: 1, height: 1), contentMode: .fill) | ||||
|                             .cornerRadius(8) | ||||
|                             .padding(.top, 21.3) | ||||
|                          | ||||
|                         HStack(spacing: 8) { | ||||
|                             if let isSpeaker = userProfile.isSpeaker { | ||||
|                                 Text(isSpeaker ? "리스너 변경" : "스피커 초대") | ||||
|                                     .font(.custom(Font.bold.rawValue, size: 15)) | ||||
|                                     .foregroundColor(Color(hex: "9970ff")) | ||||
|                                     .frame(maxWidth: .infinity) | ||||
|                                     .padding(.vertical, 13) | ||||
|                                     .cornerRadius(8) | ||||
|                                     .overlay( | ||||
|                                         RoundedRectangle(cornerRadius: 8) | ||||
|                                             .strokeBorder(lineWidth: 1) | ||||
|                                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                                     ) | ||||
|                                     .onTapGesture { | ||||
|                                         if isSpeaker { | ||||
|                                             onClickChangeListener(userProfile.userId) | ||||
|                                         } else { | ||||
|                                             onClickInviteSpeaker(userProfile.userId) | ||||
|                                         } | ||||
|                                          | ||||
|                                         isShowing = false | ||||
|                                     } | ||||
|                             } | ||||
|                              | ||||
|                             if let isManager = userProfile.isManager { | ||||
|                                 Text(isManager ? "스탭 해제" : "스탭 지정") | ||||
|                                     .font(.custom(Font.bold.rawValue, size: 15)) | ||||
|                                     .foregroundColor(Color(hex: "9970ff")) | ||||
|                                     .frame(maxWidth: .infinity) | ||||
|                                     .padding(.vertical, 13) | ||||
|                                     .cornerRadius(8) | ||||
|                                     .overlay( | ||||
|                                         RoundedRectangle(cornerRadius: 8) | ||||
|                                             .strokeBorder(lineWidth: 1) | ||||
|                                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                                     ) | ||||
|                                     .onTapGesture { | ||||
|                                         if isManager { | ||||
|                                             onClickReleaseManager(userProfile.userId) | ||||
|                                         } else { | ||||
|                                             onClickSetManager(userProfile.userId) | ||||
|                                         } | ||||
|                                          | ||||
|                                         isShowing = false | ||||
|                                     } | ||||
|                             } | ||||
|                              | ||||
|                             if (userProfile.isSpeaker != nil && !viewModel.isEqualToStaffId(creatorId: userProfile.userId)) || | ||||
|                                 (userProfile.isSpeaker != nil && userProfile.isManager != nil) { | ||||
|                                 Text("내보내기") | ||||
|                                     .font(.custom(Font.bold.rawValue, size: 15)) | ||||
|                                     .foregroundColor(Color(hex: "9970ff")) | ||||
|                                     .frame(maxWidth: .infinity) | ||||
|                                     .padding(.vertical, 13) | ||||
|                                     .cornerRadius(8) | ||||
|                                     .overlay( | ||||
|                                         RoundedRectangle(cornerRadius: 8) | ||||
|                                             .strokeBorder(lineWidth: 1) | ||||
|                                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                                     ) | ||||
|                                     .onTapGesture { | ||||
|                                         viewModel.kickOutId = userProfile.userId | ||||
|                                         viewModel.isShowKickOutPopup = true | ||||
|                                     } | ||||
|                             } | ||||
|                         } | ||||
|                         .fixedSize(horizontal: false, vertical: true) | ||||
|                         .padding(.top, 21.3) | ||||
|                          | ||||
|                         Text(userProfile.tags) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                             .foregroundColor(Color(hex: "9970ff")) | ||||
|                             .lineSpacing(3) | ||||
|                             .padding(.top, 21.3) | ||||
|                          | ||||
|                         Text(userProfile.introduce) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                             .foregroundColor(Color(hex: "909090")) | ||||
|                             .lineLimit(introduceLineLimit) | ||||
|                             .lineSpacing(3) | ||||
|                             .fixedSize(horizontal: false, vertical: true) | ||||
|                             .padding(.vertical, 20) | ||||
|                             .onTapGesture { | ||||
|                                 if let _ = introduceLineLimit { | ||||
|                                     self.introduceLineLimit = nil | ||||
|                                 } else { | ||||
|                                     self.introduceLineLimit = 2 | ||||
|                                 } | ||||
|                             } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .padding(.horizontal, 13.3) | ||||
|             .background(Color(hex: "222222").edgesIgnoringSafeArea(.all)) | ||||
|             .cornerRadius(8) | ||||
|              | ||||
|             if viewModel.isShowKickOutPopup { | ||||
|                 SodaDialog( | ||||
|                     title: "내보내기", | ||||
|                     desc: viewModel.kickOutDesc, | ||||
|                     confirmButtonTitle: "내보내기", | ||||
|                     confirmButtonAction: { | ||||
|                         viewModel.kickOut() | ||||
|                         isShowing = false | ||||
|                     }, | ||||
|                     cancelButtonTitle: "취소", | ||||
|                     cancelButtonAction: { | ||||
|                         viewModel.isShowKickOutPopup = false | ||||
|                         viewModel.kickOutDesc = "" | ||||
|                         viewModel.kickOutId = 0 | ||||
|                     } | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,21 @@ | ||||
| // | ||||
| //  GetLiveRoomDonationStatusResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct GetLiveRoomDonationStatusResponse: Decodable { | ||||
|     let donationList: [GetLiveRoomDonationItem] | ||||
|     let totalCount: Int | ||||
|     let totalCan: Int | ||||
| } | ||||
|  | ||||
| struct GetLiveRoomDonationItem: Decodable { | ||||
|     let profileImage: String | ||||
|     let nickname: String | ||||
|     let userId: Int | ||||
|     let can: Int | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| // | ||||
| //  GetLiveRoomDonationTotalResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct GetLiveRoomDonationTotalResponse: Decodable { | ||||
|     let totalDonationCan: Int | ||||
| } | ||||
| @@ -0,0 +1,25 @@ | ||||
| // | ||||
| //  GetLiveRoomUserProfileResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct GetLiveRoomUserProfileResponse: Decodable { | ||||
|     let userId: Int | ||||
|     let nickname: String | ||||
|     let profileUrl: String | ||||
|     let gender: String | ||||
|     let instagramUrl: String | ||||
|     let youtubeUrl: String | ||||
|     let websiteUrl: String | ||||
|     let blogUrl: String | ||||
|     let introduce: String | ||||
|     let tags: String | ||||
|     let isSpeaker: Bool? | ||||
|     let isManager: Bool? | ||||
|     let isFollowing: Bool? | ||||
|     let isBlock: Bool | ||||
| } | ||||
							
								
								
									
										12
									
								
								SodaLive/Sources/Live/Room/GetMemberCanResponse.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,12 @@ | ||||
| // | ||||
| //  GetMemberCanResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct GetMemberCanResponse: Decodable { | ||||
|     let can: Int | ||||
| } | ||||
							
								
								
									
										28
									
								
								SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,28 @@ | ||||
| // | ||||
| //  GetRoomInfoResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| struct GetRoomInfoResponse: Decodable { | ||||
|     let roomId: Int | ||||
|     let title: String | ||||
|     let notice: String | ||||
|     let coverImageUrl: String | ||||
|     let channelName: String | ||||
|     let rtcToken: String | ||||
|     let rtmToken: String | ||||
|     let creatorId: Int | ||||
|     let creatorNickname: String | ||||
|     let creatorProfileUrl: String | ||||
|     let isFollowing: Bool | ||||
|     let participantsCount: Int | ||||
|     let totalAvailableParticipantsCount: Int | ||||
|     let speakerList: [LiveRoomMember] | ||||
|     let listenerList: [LiveRoomMember] | ||||
|     let managerList: [LiveRoomMember] | ||||
|     let donationRankingTop3UserIds: [Int] | ||||
|     let isPrivateRoom: Bool | ||||
|     let password: String? | ||||
| } | ||||
							
								
								
									
										15
									
								
								SodaLive/Sources/Live/Room/LiveRoomDonationMessage.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
| // | ||||
| //  LiveRoomDonationMessage.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct LiveRoomDonationMessage: Decodable { | ||||
|     let uuid: String | ||||
|     let nickname: String | ||||
|     let canMessage: String | ||||
|     let donationMessage: String | ||||
| } | ||||
							
								
								
									
										15
									
								
								SodaLive/Sources/Live/Room/LiveRoomDonationRequest.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
| // | ||||
| //  LiveRoomDonationRequest.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct LiveRoomDonationRequest: Encodable { | ||||
|     let roomId: Int | ||||
|     let can: Int | ||||
|     let message: String | ||||
|     let container: String = "ios" | ||||
| } | ||||
							
								
								
									
										13
									
								
								SodaLive/Sources/Live/Room/LiveRoomKickOutRequest.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,13 @@ | ||||
| // | ||||
| //  LiveRoomKickOutRequest.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct LiveRoomKickOutRequest: Encodable { | ||||
|     let roomId: Int | ||||
|     let userId: Int | ||||
| } | ||||
							
								
								
									
										41
									
								
								SodaLive/Sources/Live/Room/LiveRoomMember.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,41 @@ | ||||
| // | ||||
| //  LiveRoomMember.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| struct LiveRoomMember: Decodable, Hashable { | ||||
|     let id: Int | ||||
|     let nickname: String | ||||
|     let profileImage: String | ||||
|     let role: LiveRoomMemberRole | ||||
| } | ||||
|  | ||||
| enum LiveRoomMemberRole: String, Decodable { | ||||
|     case LISTENER, SPEAKER, MANAGER | ||||
| } | ||||
|  | ||||
| enum LiveRoomProfileItemType: String { | ||||
|     case MASTER, SPEAKER_TITLE, LISTENER_TITLE, USER | ||||
| } | ||||
|  | ||||
| protocol LiveRoomProfileItem: Hashable { | ||||
|     var type: LiveRoomProfileItemType { get set } | ||||
| } | ||||
|  | ||||
| struct LiveRoomProfileItemSpeakerTitle: LiveRoomProfileItem { | ||||
|     var type: LiveRoomProfileItemType = .SPEAKER_TITLE | ||||
| } | ||||
|  | ||||
| struct LiveRoomProfileItemListenerTitle: LiveRoomProfileItem { | ||||
|     var type: LiveRoomProfileItemType = .LISTENER_TITLE | ||||
| } | ||||
|  | ||||
| struct LiveRoomProfileItemMaster: LiveRoomProfileItem { | ||||
|     var type: LiveRoomProfileItemType = .MASTER | ||||
| } | ||||
|  | ||||
| struct LiveRoomProfileItemUser: LiveRoomProfileItem { | ||||
|     var type: LiveRoomProfileItemType = .USER | ||||
| } | ||||
							
								
								
									
										12
									
								
								SodaLive/Sources/Live/Room/LiveRoomRequestType.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,12 @@ | ||||
| // | ||||
| //  LiveRoomRequestType.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| enum LiveRoomRequestType: String { | ||||
|     case REQUEST_SPEAKER, REQUEST_SPEAKER_ALLOW, INVITE_SPEAKER, CHANGE_LISTENER, KICK_OUT, SET_MANAGER, RELEASE_MANAGER | ||||
| } | ||||
							
								
								
									
										41
									
								
								SodaLive/Sources/Live/Room/LiveRoomTopCreatorView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,41 @@ | ||||
| // | ||||
| //  LiveRoomTopCreatorView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| import Kingfisher | ||||
|  | ||||
| struct LiveRoomTopCreatorView: View { | ||||
|      | ||||
|     var nickname: String | ||||
|     var profileImageUrl: String | ||||
|     var isFollowing: Bool | ||||
|     var onClickProfile: () -> Void | ||||
|     var onClickFollow: (Bool) -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         HStack(spacing: 5.3) { | ||||
|             KFImage(URL(string: profileImageUrl)) | ||||
|                 .resizable() | ||||
|                 .scaledToFill() | ||||
|                 .frame(width: 33.3, height: 33.3) | ||||
|                 .clipShape(Circle()) | ||||
|                 .onTapGesture { onClickProfile() } | ||||
|              | ||||
|             Image("ic_crown") | ||||
|              | ||||
|             Text(nickname) | ||||
|                 .font(.custom(Font.light.rawValue, size: 12)) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|              | ||||
|             Spacer() | ||||
|              | ||||
|             Image(isFollowing ? "btn_following" : "btn_follow") | ||||
|                 .onTapGesture { onClickFollow(isFollowing) } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										932
									
								
								SodaLive/Sources/Live/Room/LiveRoomView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,932 @@ | ||||
| // | ||||
| //  LiveRoomView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
| import PopupView | ||||
|  | ||||
| struct LiveRoomView: View { | ||||
|     @State private var isShowingNewChat = false | ||||
|     @State private var isShowPhotoPicker = false | ||||
|      | ||||
|     let columns = [ | ||||
|         GridItem(.flexible()), | ||||
|         GridItem(.flexible()), | ||||
|         GridItem(.flexible()), | ||||
|         GridItem(.flexible()), | ||||
|         GridItem(.flexible()) | ||||
|     ] | ||||
|      | ||||
|     let chatColumns = [GridItem(.flexible())] | ||||
|      | ||||
|     @StateObject var keyboardHandler = KeyboardHandler() | ||||
|     @StateObject var viewModel = LiveRoomViewModel() | ||||
|      | ||||
|     var body: some View { | ||||
|         ZStack { | ||||
|             Color.black.edgesIgnoringSafeArea(.all) | ||||
|              | ||||
|             VStack(spacing: 0) { | ||||
|                 VStack(alignment: .leading, spacing: 0) { | ||||
|                     HStack(spacing: 6.7) { | ||||
|                         Text( | ||||
|                             UserDefaults.int(forKey: .userId) == viewModel.liveRoomInfo?.creatorId ? | ||||
|                             "라이브 종료": | ||||
|                                 "나가기" | ||||
|                         ) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                         .foregroundColor(Color(hex: "ff5c49")) | ||||
|                         .padding(.horizontal, 14.3) | ||||
|                         .padding(.vertical, 8.3) | ||||
|                         .overlay( | ||||
|                             RoundedRectangle(cornerRadius: 13.3) | ||||
|                                 .stroke(Color(hex: "ff5c49"), lineWidth: 1) | ||||
|                         ) | ||||
|                         .onTapGesture { | ||||
|                             if let liveRoomInfo = viewModel.liveRoomInfo, liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) { | ||||
|                                 viewModel.isShowLiveEndPopup = true | ||||
|                             } else { | ||||
|                                 viewModel.isShowQuitPopup = true | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         Spacer() | ||||
|                          | ||||
|                         Text(viewModel.isBgOn ? "배경 ON" : "배경 OFF") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                             .foregroundColor(Color(hex: viewModel.isBgOn ? "9970ff" : "eeeeee")) | ||||
|                             .padding(.horizontal, 14.3) | ||||
|                             .padding(.vertical, 8.3) | ||||
|                             .overlay( | ||||
|                                 RoundedRectangle(cornerRadius: 13.3) | ||||
|                                     .stroke(Color(hex: viewModel.isBgOn ? "9970ff" : "bbbbbb"), lineWidth: 1) | ||||
|                             ) | ||||
|                             .onTapGesture { | ||||
|                                 viewModel.isBgOn.toggle() | ||||
|                             } | ||||
|                          | ||||
|                         HStack(spacing: 4.7) { | ||||
|                             Image("ic_share") | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 16, height: 16) | ||||
|                         } | ||||
|                         .padding(.horizontal, 14.3) | ||||
|                         .padding(.vertical, 6) | ||||
|                         .overlay( | ||||
|                             RoundedRectangle(cornerRadius: 13.3) | ||||
|                                 .stroke(Color(hex: "bbbbbb"), lineWidth: 1) | ||||
|                         ) | ||||
|                         .onTapGesture { | ||||
|                             viewModel.shareRoom() | ||||
|                         } | ||||
|                          | ||||
|                         if let liveRoomInfo = viewModel.liveRoomInfo, | ||||
|                            liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) { | ||||
|                             HStack(spacing: 4.7) { | ||||
|                                 Image("ic_edit") | ||||
|                                     .resizable() | ||||
|                                     .frame(width: 16, height: 16) | ||||
|                             } | ||||
|                             .padding(.horizontal, 14.3) | ||||
|                             .padding(.vertical, 6) | ||||
|                             .overlay( | ||||
|                                 RoundedRectangle(cornerRadius: 13.3) | ||||
|                                     .stroke(Color(hex: "bbbbbb"), lineWidth: 1) | ||||
|                             ) | ||||
|                             .onTapGesture { | ||||
|                                 viewModel.isShowEditRoomInfoDialog = true | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                     .padding(.horizontal, 13.3) | ||||
|                      | ||||
|                     if let liveRoomInfo = viewModel.liveRoomInfo { | ||||
|                         ZStack { | ||||
|                             VStack(alignment: .leading, spacing: 0) { | ||||
|                                 Text(liveRoomInfo.title) | ||||
|                                     .font(.custom(Font.bold.rawValue, size: 15.3)) | ||||
|                                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                                     .lineLimit(1) | ||||
|                                     .padding(.top, 16.7) | ||||
|                                     .padding(.horizontal, 13.3) | ||||
|                                  | ||||
|                                 LiveRoomTopCreatorView( | ||||
|                                     nickname: liveRoomInfo.creatorNickname, | ||||
|                                     profileImageUrl: liveRoomInfo.creatorProfileUrl, | ||||
|                                     isFollowing: liveRoomInfo.isFollowing, | ||||
|                                     onClickProfile: { | ||||
|                                         if liveRoomInfo.creatorId != UserDefaults.int(forKey: .userId) { | ||||
|                                             viewModel.getUserProfile(userId: liveRoomInfo.creatorId) | ||||
|                                         } | ||||
|                                     }, | ||||
|                                     onClickFollow: { | ||||
|                                         if $0 { | ||||
|                                             viewModel.unRegisterNotification() | ||||
|                                         } else { | ||||
|                                             viewModel.registerNotification() | ||||
|                                         } | ||||
|                                     } | ||||
|                                 ) | ||||
|                                 .padding(.top, 16.7) | ||||
|                                 .padding(.horizontal, 13.3) | ||||
|                                  | ||||
|                                 Rectangle() | ||||
|                                     .frame(height: 1) | ||||
|                                     .foregroundColor(Color(hex: "909090").opacity(0.3)) | ||||
|                                     .padding(.horizontal, 13.3) | ||||
|                                     .padding(.top, 8) | ||||
|                                  | ||||
|                                 NotificationView(liveRoomInfo: liveRoomInfo) | ||||
|                             } | ||||
|                              | ||||
|                             if viewModel.isMute { | ||||
|                                 Image("img_noti_mute") | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         if viewModel.isShowNotice { | ||||
|                             HStack(alignment: .top, spacing: 8) { | ||||
|                                 Text("[공지]") | ||||
|                                     .font(.custom(Font.bold.rawValue, size: 11.3)) | ||||
|                                     .foregroundColor(.white) | ||||
|                                  | ||||
|                                 AttributedTextView( | ||||
|                                     attributedString: makeAttributedString(liveRoomInfo.notice), | ||||
|                                     lineLimit: viewModel.isExpandNotice ? Int.max : 1 | ||||
|                                 ) { | ||||
|                                     UIApplication.shared.open($0) | ||||
|                                      | ||||
|                                 } | ||||
|                                 .fixedSize(horizontal: false, vertical: true) | ||||
|                                 .lineSpacing(6) | ||||
|                             } | ||||
|                             .padding(.horizontal, 26.7) | ||||
|                             .padding(.vertical, 13.3) | ||||
|                             .frame(width: screenSize().width, alignment: .leading) | ||||
|                             .background(Color(hex: "3d2a6c")) | ||||
|                             .padding(.top, 10) | ||||
|                             .onTapGesture { | ||||
|                                 viewModel.isExpandNotice.toggle() | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         if !viewModel.isSpeakerFold { | ||||
|                             HStack(spacing: 0) { | ||||
|                                 Text("스피커") | ||||
|                                     .font(.custom(Font.bold.rawValue, size: 12)) | ||||
|                                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                                  | ||||
|                                 Spacer() | ||||
|                             } | ||||
|                             .padding(.top, 20) | ||||
|                             .padding(.horizontal, 23.3) | ||||
|                              | ||||
|                             LazyVGrid(columns: columns) { | ||||
|                                 ForEach(liveRoomInfo.speakerList, id: \.self) { speaker in | ||||
|                                     VStack(spacing: 6.7) { | ||||
|                                         ZStack { | ||||
|                                             KFImage(URL(string: speaker.profileImage)) | ||||
|                                                 .resizable() | ||||
|                                                 .scaledToFill() | ||||
|                                                 .frame(width: 46.7, height: 46.7, alignment: .top) | ||||
|                                                 .clipShape(Circle()) | ||||
|                                                 .overlay( | ||||
|                                                     Circle() | ||||
|                                                         .stroke( | ||||
|                                                             Color(hex: "9970ff"), | ||||
|                                                             lineWidth: viewModel.activeSpeakers.contains(UInt(speaker.id)) ? 3 : 0 | ||||
|                                                         ) | ||||
|                                                 ) | ||||
|                                              | ||||
|                                             if viewModel.muteSpeakers.contains(UInt(speaker.id)) { | ||||
|                                                 Image("ic_mute") | ||||
|                                                     .resizable() | ||||
|                                                     .frame(width: 46.7, height: 46.7) | ||||
|                                             } | ||||
|                                              | ||||
|                                             VStack(alignment: .leading, spacing: 0) { | ||||
|                                                 HStack(spacing: 0) { | ||||
|                                                     Spacer() | ||||
|                                                      | ||||
|                                                     if liveRoomInfo.creatorId == speaker.id { | ||||
|                                                         Image("ic_crown") | ||||
|                                                             .resizable() | ||||
|                                                             .frame(width: 16.7, height: 16.7) | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                                  | ||||
|                                                 Spacer() | ||||
|                                             } | ||||
|                                         } | ||||
|                                         .frame(width: 46.7, height: 46.7) | ||||
|                                          | ||||
|                                         Text(speaker.nickname) | ||||
|                                             .font(.custom(Font.light.rawValue, size: 12)) | ||||
|                                             .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                                             .lineLimit(1) | ||||
|                                     } | ||||
|                                     .onTapGesture { | ||||
|                                         viewModel.selectedProfile = speaker | ||||
|                                         viewModel.isShowProfilePopup = true | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                             .padding(.top, 16.7) | ||||
|                             .padding(.horizontal, 23.3) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 .padding(.vertical, 16.7) | ||||
|                 .frame(width: screenSize().width) | ||||
|                 .background(Color(hex: "222222")) | ||||
|                 .cornerRadius(16.7, corners: [.topLeft, .topRight]) | ||||
|                  | ||||
|                 ZStack(alignment: .top) { | ||||
|                     ScrollViewReader { proxy in | ||||
|                         ZStack(alignment: .bottomTrailing) { | ||||
|                             if let liveRoomInfo = viewModel.liveRoomInfo, viewModel.isBgOn { | ||||
|                                 GeometryReader { proxy in | ||||
|                                     KFImage(URL(string: liveRoomInfo.coverImageUrl)) | ||||
|                                         .resizable() | ||||
|                                         .scaledToFill() | ||||
|                                         .frame(width: proxy.size.width, height: proxy.size.height, alignment: .center) | ||||
|                                         .clipped() | ||||
|                                      | ||||
|                                     Color.black.opacity(0.4) | ||||
|                                 } | ||||
|                             } | ||||
|                              | ||||
|                             VStack(alignment: .leading, spacing: 0) { | ||||
|                                 ScrollView(.vertical, showsIndicators: false) { | ||||
|                                     scrollObservableView | ||||
|                                     ChatView() | ||||
|                                         .frame(width: screenSize().width) | ||||
|                                 } | ||||
|                                 .rotationEffect(Angle(degrees: 180)) | ||||
|                                 .onTapGesture { hideKeyboard() } | ||||
|                                 .onPreferenceChange(ScrollOffsetKey.self) { | ||||
|                                     viewModel.setOffset($0) | ||||
|                                 } | ||||
|                                  | ||||
|                                 InputChatView { | ||||
|                                     isShowingNewChat = false | ||||
|                                     proxy.scrollTo(viewModel.messages.count - 1, anchor: .center) | ||||
|                                 }.padding(.bottom, keyboardHandler.keyboardHeight > 0 ? 0 : 15) | ||||
|                             } | ||||
|                              | ||||
|                             VStack(spacing: 13.3) { | ||||
|                                 if viewModel.role == .SPEAKER { | ||||
|                                     Image(viewModel.isMute ? "ic_mic_off" : "ic_mic_on") | ||||
|                                         .resizable() | ||||
|                                         .frame(width: 26.7, height: 26.7) | ||||
|                                         .padding(11) | ||||
|                                         .background(Color(hex: "525252").opacity(0.6)) | ||||
|                                         .cornerRadius(10) | ||||
|                                         .padding(.bottom, 13.3) | ||||
|                                         .onTapGesture { | ||||
|                                             viewModel.toggleMute() | ||||
|                                         } | ||||
|                                 } | ||||
|                                  | ||||
|                                 Image(viewModel.isSpeakerMute ? "ic_speaker_off" : "ic_speaker_on") | ||||
|                                     .resizable() | ||||
|                                     .frame(width: 26.7, height: 26.7) | ||||
|                                     .padding(11) | ||||
|                                     .background(Color(hex: "525252").opacity(0.6)) | ||||
|                                     .cornerRadius(10) | ||||
|                                     .padding(.bottom, 13.3) | ||||
|                                     .onTapGesture { | ||||
|                                         viewModel.toggleSpeakerMute() | ||||
|                                     } | ||||
|                                  | ||||
|                                 if let liveRoomInfo = viewModel.liveRoomInfo, liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) && UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue { | ||||
|                                     Image("ic_donation_message_list") | ||||
|                                         .resizable() | ||||
|                                         .frame(width: 26.7, height: 26.7) | ||||
|                                         .padding(11) | ||||
|                                         .background(Color(hex: "525252").opacity(0.6)) | ||||
|                                         .cornerRadius(10) | ||||
|                                         .onTapGesture { | ||||
|                                             viewModel.isShowDonationMessagePopup = true | ||||
|                                         } | ||||
|                                 } else { | ||||
|                                     Image("ic_donation") | ||||
|                                         .resizable() | ||||
|                                         .frame(width: 26.7, height: 26.7) | ||||
|                                         .padding(11) | ||||
|                                         .background(Color(hex: "525252").opacity(0.6)) | ||||
|                                         .cornerRadius(10) | ||||
|                                         .onTapGesture { | ||||
|                                             viewModel.isShowDonationPopup = true | ||||
|                                         } | ||||
|                                 } | ||||
|                             } | ||||
|                             .padding(.trailing, 16.7) | ||||
|                             .padding(.bottom, 85) | ||||
|                              | ||||
|                             if isShowingNewChat { | ||||
|                                 NewChatView{ | ||||
|                                     isShowingNewChat = false | ||||
|                                     proxy.scrollTo(viewModel.messages.count - 1, anchor: .center) | ||||
|                                 }.padding(.bottom, 70) | ||||
|                             } | ||||
|                         } | ||||
|                         .frame(width: screenSize().width) | ||||
|                         .animation(nil) | ||||
|                     } | ||||
|                      | ||||
|                     HStack(spacing: 0) { | ||||
|                         Spacer() | ||||
|                          | ||||
|                         HStack(spacing: 6.7) { | ||||
|                             Image(viewModel.isSpeakerFold ? "ic_live_detail_bottom" : "ic_live_detail_top") | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 20, height: 20) | ||||
|                              | ||||
|                             Text(viewModel.isSpeakerFold ? "펼치기" : "접기") | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                                 .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                         } | ||||
|                         .padding(.vertical, 6.7) | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                         .background(Color(hex: "222222")) | ||||
|                         .cornerRadius(10, corners: [.bottomLeft, .bottomRight]) | ||||
|                         .onTapGesture { | ||||
|                             viewModel.isSpeakerFold.toggle() | ||||
|                         } | ||||
|                     } | ||||
|                     .background( | ||||
|                         LinearGradient( | ||||
|                             gradient: Gradient(colors: [Color(hex: "222222").opacity(0.95), Color.black.opacity(0.005)]), | ||||
|                             startPoint: .top, | ||||
|                             endPoint: .bottom | ||||
|                         ).ignoresSafeArea() | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|             .popup(isPresented: $viewModel.isShowErrorPopup, type: .toast, position: .top, autohideIn: 1.3) { | ||||
|                 GeometryReader { geo in | ||||
|                     HStack { | ||||
|                         Spacer() | ||||
|                         Text(viewModel.errorMessage) | ||||
|                             .padding(.vertical, 13.3) | ||||
|                             .frame(width: geo.size.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() | ||||
|                     } | ||||
|                     .onDisappear { | ||||
|                         viewModel.quitRoom() | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .cornerRadius(16.7, corners: [.topLeft, .topRight]) | ||||
|             .offset(y: -(keyboardHandler.keyboardHeight > 0 ? keyboardHandler.keyboardHeight : 0)) | ||||
|             .onAppear { | ||||
|                 UIApplication.shared.isIdleTimerDisabled = true | ||||
|                 UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) | ||||
|                  | ||||
|                 viewModel.getMemberCan() | ||||
|                 viewModel.initAgoraEngine() | ||||
|                 viewModel.getRoomInfo() | ||||
|                  | ||||
|                 NotificationCenter.default.addObserver( | ||||
|                     forName: UIApplication.willTerminateNotification, | ||||
|                     object: nil, | ||||
|                     queue: .main) { _ in | ||||
|                         viewModel.quitRoom() | ||||
|                         sleep(3) | ||||
|                     } | ||||
|             } | ||||
|             .onDisappear { | ||||
|                 UIApplication.shared.isIdleTimerDisabled = false | ||||
|                 NotificationCenter.default.removeObserver(self) | ||||
|             } | ||||
|              | ||||
|             ZStack { | ||||
|                 if viewModel.isShowProfilePopup, let liveRoomInfo = viewModel.liveRoomInfo, let selectedProfile = viewModel.selectedProfile { | ||||
|                     LiveRoomProfileDialog( | ||||
|                         isShowing: $viewModel.isShowProfilePopup, | ||||
|                         profileInfo: selectedProfile, | ||||
|                         creatorId: liveRoomInfo.creatorId, | ||||
|                         isSpeaker: viewModel.role == .SPEAKER, | ||||
|                         onClickInviteSpeaker: { inviteSpeaker(peerId: $0) }, | ||||
|                         onClickChangeListener: { | ||||
|                             if $0 == UserDefaults.int(forKey: .userId) { | ||||
|                                 viewModel.setListener() | ||||
|                                 return | ||||
|                             } | ||||
|                              | ||||
|                             viewModel.changeListener(peerId: $0) | ||||
|                         } | ||||
|                     ) | ||||
|                 } | ||||
|                  | ||||
|                 if viewModel.isShowDonationPopup { | ||||
|                     LiveRoomDonationDialogView(isShowing: $viewModel.isShowDonationPopup, isAudioContentDonation: false) { can, message in | ||||
|                         viewModel.donation(can: can, message: message) | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 if viewModel.isShowQuitPopup { | ||||
|                     SodaDialog( | ||||
|                         title: "라이브 나가기", | ||||
|                         desc: "라이브에서 나가시겠습니까?", | ||||
|                         confirmButtonTitle: "예", | ||||
|                         confirmButtonAction: { | ||||
|                             viewModel.isShowQuitPopup = false | ||||
|                             viewModel.quitRoom() | ||||
|                         }, | ||||
|                         cancelButtonTitle: "아니오", | ||||
|                         cancelButtonAction: { | ||||
|                             viewModel.isShowQuitPopup = false | ||||
|                         } | ||||
|                     ) | ||||
|                 } | ||||
|                  | ||||
|                 if viewModel.isShowLiveEndPopup { | ||||
|                     SodaDialog( | ||||
|                         title: "라이브 종료", | ||||
|                         desc: "라이브를 종료하시겠습니까?\n" + | ||||
|                         "라이브를 종료하면 대화내용은\n" + | ||||
|                         "저장되지 않고 사라집니다.\n" + | ||||
|                         "참여자들 또한 라이브가 종료되어\n" + | ||||
|                         "강제퇴장 됩니다.", | ||||
|                         confirmButtonTitle: "예", | ||||
|                         confirmButtonAction: { | ||||
|                             viewModel.isShowLiveEndPopup = false | ||||
|                             viewModel.quitRoom() | ||||
|                         }, | ||||
|                         cancelButtonTitle: "아니오", | ||||
|                         cancelButtonAction: { | ||||
|                             viewModel.isShowLiveEndPopup = false | ||||
|                         } | ||||
|                     ) | ||||
|                 } | ||||
|                  | ||||
|                 if viewModel.isShowPopup { | ||||
|                     LiveRoomDialogView( | ||||
|                         content: viewModel.popupContent, | ||||
|                         cancelTitle: viewModel.popupCancelTitle, | ||||
|                         cancelAction: viewModel.popupCancelAction, | ||||
|                         confirmTitle: viewModel.popupConfirmTitle, | ||||
|                         confirmAction: viewModel.popupConfirmAction | ||||
|                     ).onAppear { | ||||
|                         if viewModel.popupConfirmTitle == nil && viewModel.popupConfirmAction == nil { | ||||
|                             DispatchQueue.main.asyncAfter(deadline: .now() + 2) { | ||||
|                                 viewModel.isShowPopup = false | ||||
|                                 viewModel.popupCancelTitle = nil | ||||
|                                 viewModel.popupCancelAction = nil | ||||
|                                 viewModel.popupConfirmTitle = nil | ||||
|                                 viewModel.popupConfirmAction = nil | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             ZStack { | ||||
|                 if viewModel.isShowProfileList, let liveRoomInfo = viewModel.liveRoomInfo { | ||||
|                     LiveRoomProfilesDialogView( | ||||
|                         isShowing: $viewModel.isShowProfileList, | ||||
|                         viewModel: viewModel, | ||||
|                         roomInfo: liveRoomInfo, | ||||
|                         isShowRequestSpeaker: viewModel.role != .SPEAKER, | ||||
|                         onClickRequestSpeaker: { | ||||
|                             viewModel.requestSpeaker() | ||||
|                         }, | ||||
|                         registerNotification: { viewModel.registerNotification() }, | ||||
|                         unRegisterNotification: { viewModel.unRegisterNotification() }, | ||||
|                         onClickProfile: { | ||||
|                             if $0 != UserDefaults.int(forKey: .userId) { | ||||
|                                 viewModel.getUserProfile(userId: $0) | ||||
|                             } | ||||
|                         } | ||||
|                     ) | ||||
|                 } | ||||
|                  | ||||
|                 if viewModel.isShowUserProfilePopup, let userProfile = viewModel.userProfile { | ||||
|                     Color.black.opacity(0.7) | ||||
|                         .edgesIgnoringSafeArea(.all) | ||||
|                      | ||||
|                     LiveRoomUserProfileDialogView( | ||||
|                         isShowing: $viewModel.isShowUserProfilePopup, | ||||
|                         viewModel: viewModel, | ||||
|                         userProfile: userProfile, | ||||
|                         onClickSetManager: { | ||||
|                             viewModel.setManagerMessageToPeer(userId: $0) | ||||
|                             viewModel.setManager(userId: $0) | ||||
|                         }, | ||||
|                         onClickReleaseManager: { viewModel.changeListener(peerId: $0, isFromManager: true) }, | ||||
|                         onClickFollow: { viewModel.registerNotification(creatorId: $0, isGetUserProfile: true) }, | ||||
|                         onClickUnFollow: { viewModel.unRegisterNotification(creatorId: $0, isGetUserProfile: true) }, | ||||
|                         onClickInviteSpeaker: { inviteSpeaker(peerId: $0) }, | ||||
|                         onClickChangeListener: { | ||||
|                             viewModel.changeListener(peerId: $0) | ||||
|                         }, | ||||
|                         onClickMenu: { userId, userNickname, isBlocked in | ||||
|                             viewModel.reportUserId = userId | ||||
|                             viewModel.reportUserNickname = userNickname | ||||
|                             viewModel.reportUserIsBlocked = isBlocked | ||||
|                             viewModel.isShowReportMenu = true | ||||
|                         } | ||||
|                     ) | ||||
|                     .padding(20) | ||||
|                     .popup(isPresented: $viewModel.isShowReportPopup, type: .toast, position: .top, autohideIn: 1.3) { | ||||
|                         GeometryReader { geo in | ||||
|                             HStack { | ||||
|                                 Spacer() | ||||
|                                 Text(viewModel.reportMessage) | ||||
|                                     .padding(.vertical, 13.3) | ||||
|                                     .frame(width: geo.size.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() | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 if viewModel.isShowReportMenu { | ||||
|                     VStack(spacing: 0) { | ||||
|                         ProfileReportMenuView( | ||||
|                             isShowing: $viewModel.isShowReportMenu, | ||||
|                             isBlockedUser: viewModel.reportUserIsBlocked, | ||||
|                             userBlockAction: { viewModel.isShowUesrBlockConfirm = true }, | ||||
|                             userUnBlockAction: { viewModel.userUnBlock() }, | ||||
|                             userReportAction: { viewModel.isShowUesrReportView = true }, | ||||
|                             profileReportAction: { viewModel.isShowProfileReportConfirm = true } | ||||
|                         ) | ||||
|                          | ||||
|                         Rectangle() | ||||
|                             .foregroundColor(Color(hex: "222222")) | ||||
|                             .frame(width: screenSize().width, height: 15.3) | ||||
|                     } | ||||
|                     .ignoresSafeArea() | ||||
|                 } | ||||
|                  | ||||
|                 if viewModel.isShowUesrBlockConfirm { | ||||
|                     UserBlockConfirmDialogView( | ||||
|                         isShowing: $viewModel.isShowUesrBlockConfirm, | ||||
|                         nickname: viewModel.reportUserNickname, | ||||
|                         confirmAction: { viewModel.userBlock() } | ||||
|                     ) | ||||
|                 } | ||||
|                  | ||||
|                 if viewModel.isShowUesrReportView { | ||||
|                     UserReportDialogView( | ||||
|                         isShowing: $viewModel.isShowUesrReportView, | ||||
|                         confirmAction: { reason in | ||||
|                             viewModel.report(type: .USER, reason: reason) | ||||
|                         } | ||||
|                     ) | ||||
|                 } | ||||
|                  | ||||
|                 if viewModel.isShowProfileReportConfirm { | ||||
|                     ProfileReportDialogView( | ||||
|                         isShowing: $viewModel.isShowProfileReportConfirm, | ||||
|                         confirmAction: { | ||||
|                             viewModel.report(type: .PROFILE) | ||||
|                         } | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             if viewModel.isLoading && viewModel.liveRoomInfo == nil { | ||||
|                 LoadingView() | ||||
|             } | ||||
|         } | ||||
|         .ignoresSafeArea(.keyboard) | ||||
|         .edgesIgnoringSafeArea(keyboardHandler.keyboardHeight > 0 ? .bottom : .init()) | ||||
|         .sheet( | ||||
|             isPresented: $viewModel.isShowShareView, | ||||
|             onDismiss: { viewModel.shareMessage = "" }, | ||||
|             content: { | ||||
|                 ActivityViewController(activityItems: [viewModel.shareMessage]) | ||||
|             } | ||||
|         ) | ||||
|         .sheet(isPresented: $isShowPhotoPicker) { | ||||
|             ImagePicker( | ||||
|                 isShowing: $isShowPhotoPicker, | ||||
|                 selectedImage: $viewModel.coverImage, | ||||
|                 sourceType: .photoLibrary | ||||
|             ) | ||||
|         } | ||||
|         .sheet(isPresented: $viewModel.isShowEditRoomInfoDialog) { | ||||
|             if let liveRoomInfo = viewModel.liveRoomInfo { | ||||
|                 LiveRoomInfoEditDialog( | ||||
|                     isShowing: $viewModel.isShowEditRoomInfoDialog, | ||||
|                     isShowPhotoPicker: $isShowPhotoPicker, | ||||
|                     viewModel: viewModel, | ||||
|                     isLoading: viewModel.isLoading, | ||||
|                     currentTitle: liveRoomInfo.title, | ||||
|                     currentNotice: liveRoomInfo.notice, | ||||
|                     coverImageUrl: liveRoomInfo.coverImageUrl, | ||||
|                     coverImage: viewModel.coverImage | ||||
|                 ) { newTitle, newNotice in | ||||
|                     self.viewModel.editLiveRoomInfo( | ||||
|                         title: newTitle, | ||||
|                         notice: newNotice | ||||
|                     ) | ||||
|                 } | ||||
|             } else { | ||||
|                 EmptyView() | ||||
|                     .onAppear { | ||||
|                         viewModel.isShowEditRoomInfoDialog = false | ||||
|                     } | ||||
|             } | ||||
|         } | ||||
|         .sheet(isPresented: $viewModel.isShowDonationRankingPopup) { | ||||
|             LiveRoomDonationRankingDialog(isShowing: $viewModel.isShowDonationRankingPopup) | ||||
|         } | ||||
|         .sheet(isPresented: $viewModel.isShowDonationMessagePopup) { | ||||
|             LiveRoomDonationMessageDialog(isShowing: $viewModel.isShowDonationMessagePopup) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func makeAttributedString(_ text: String) -> NSAttributedString { | ||||
|         let attributedString = NSMutableAttributedString(string: text) | ||||
|          | ||||
|         let urlRegex = try! NSRegularExpression(pattern: "\\b(https?://\\S+\\b|www\\.\\S+\\b)") | ||||
|         let matches = urlRegex.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text)) | ||||
|          | ||||
|         for match in matches { | ||||
|             let url = (text as NSString).substring(with: match.range) | ||||
|             if let detectedURL = URL(string: url) { | ||||
|                 attributedString.addAttribute(.link, value: detectedURL, range: match.range) | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         return attributedString | ||||
|     } | ||||
|      | ||||
|      | ||||
|     private func inviteSpeaker(peerId: Int) { | ||||
|         if viewModel.liveRoomInfo!.speakerList.count <= 9 { | ||||
|             viewModel.inviteSpeaker(peerId: peerId) | ||||
|             self.viewModel.popupContent = "스피커 요청을 보냈습니다.\n잠시만 기다려 주세요." | ||||
|             self.viewModel.isShowPopup = true | ||||
|         } else { | ||||
|             viewModel.popupContent = "스피커 정원을 초과했습니다." | ||||
|             viewModel.isShowPopup = true | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private var scrollObservableView: some View { | ||||
|         GeometryReader { proxy in | ||||
|             let offsetY = proxy.frame(in: .global).origin.y | ||||
|             Color.clear | ||||
|                 .preference( | ||||
|                     key: ScrollOffsetKey.self, | ||||
|                     value: offsetY | ||||
|                 ) | ||||
|                 .onAppear { | ||||
|                     viewModel.setOriginOffset(offsetY) | ||||
|                 } | ||||
|         } | ||||
|         .frame(height: 0) | ||||
|     } | ||||
|      | ||||
|     struct ScrollOffsetKey: PreferenceKey { | ||||
|         static var defaultValue: CGFloat = .zero | ||||
|         static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { | ||||
|             value += nextValue() | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func NotificationView(liveRoomInfo: GetRoomInfoResponse) -> some View { | ||||
|         HStack(spacing: 8) { | ||||
|             Image( | ||||
|                 viewModel.isShowNotice ? | ||||
|                 "ic_notice_selected" : | ||||
|                     "ic_notice_normal" | ||||
|             ) | ||||
|             .onTapGesture { | ||||
|                 viewModel.isShowNotice.toggle() | ||||
|             } | ||||
|             .padding(.trailing, 10) | ||||
|              | ||||
|             Spacer() | ||||
|              | ||||
|             HStack(spacing: 4.7) { | ||||
|                 Image("ic_donation_status") | ||||
|                     .resizable() | ||||
|                     .frame(width: 16, height: 16) | ||||
|                  | ||||
|                 Text("\(viewModel.totalDonationCan)") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                     .foregroundColor(Color(hex: "bbbbbb")) | ||||
|             } | ||||
|             .padding(.horizontal, 11.5) | ||||
|             .padding(.vertical, 5.3) | ||||
|             .overlay( | ||||
|                 RoundedRectangle(cornerRadius: 12.8) | ||||
|                     .strokeBorder(lineWidth: 1) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|             ) | ||||
|             .onTapGesture { | ||||
|                 viewModel.isShowDonationRankingPopup = true | ||||
|             } | ||||
|              | ||||
|             HStack(spacing: 0) { | ||||
|                 Text("참여자") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                     .foregroundColor(Color(hex: "bbbbbb")) | ||||
|                  | ||||
|                 Text("\(liveRoomInfo.participantsCount)") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                     .foregroundColor(Color(hex: "9970ff")) | ||||
|                     .padding(.leading, 6.7) | ||||
|             } | ||||
|             .padding(.horizontal, 11.5) | ||||
|             .padding(.vertical, 7.3) | ||||
|             .overlay( | ||||
|                 RoundedRectangle(cornerRadius: 12.8) | ||||
|                     .strokeBorder(lineWidth: 1) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|             ) | ||||
|             .onTapGesture { | ||||
|                 viewModel.isShowProfileList = true | ||||
|             } | ||||
|         } | ||||
|         .padding(.top, 13.3) | ||||
|         .padding(.horizontal, 13.3) | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func NewChatView(scrollToBottom: @escaping () -> Void) -> some View { | ||||
|         HStack(spacing: 0) { | ||||
|             Spacer() | ||||
|              | ||||
|             HStack(spacing: 6.7) { | ||||
|                 Image("ic_bottom_white") | ||||
|                 Text("새로운 채팅") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|             } | ||||
|             .padding(.vertical, 8) | ||||
|             .padding(.horizontal, 13.3) | ||||
|             .background(Color(hex: "555555").opacity(0.8)) | ||||
|             .cornerRadius(16.7) | ||||
|             .padding(.bottom, 13.3) | ||||
|             .onTapGesture { scrollToBottom() } | ||||
|              | ||||
|             Spacer() | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func InputChatView(scrollToBottom: @escaping () -> Void) -> some View { | ||||
|         HStack(spacing: 0) { | ||||
|             TextField("채팅을 입력하세요", text: $viewModel.chatMessage) | ||||
|                 .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 { | ||||
|                     viewModel.sendMessage() | ||||
|                     scrollToBottom() | ||||
|                 } | ||||
|         } | ||||
|         .background(Color(hex: "232323")) | ||||
|         .cornerRadius(10) | ||||
|         .overlay( | ||||
|             RoundedRectangle(cornerRadius: 10) | ||||
|                 .strokeBorder(lineWidth: 1) | ||||
|                 .foregroundColor(Color(hex: "eeeeee")) | ||||
|         ) | ||||
|         .padding(13.3) | ||||
|     } | ||||
|      | ||||
|     @ViewBuilder | ||||
|     func ChatView() -> some View { | ||||
|         LazyVGrid(columns: chatColumns, alignment: .leading, spacing: 20) { | ||||
|             ForEach(0..<viewModel.messages.count, id: \.self) { index in | ||||
|                 switch (viewModel.messages[index].type) { | ||||
|                 case LiveRoomChatType.DONATION: | ||||
|                     let chatMessage = viewModel.messages[index] as! LiveRoomDonationChat | ||||
|                     LiveRoomDonationChatItemView(chatMessage: chatMessage) | ||||
|                      | ||||
|                 case LiveRoomChatType.JOIN: | ||||
|                     let chatMessage = viewModel.messages[index] as! LiveRoomJoinChat | ||||
|                     LiveRoomJoinChatItemView(chatMessage: chatMessage) | ||||
|                      | ||||
|                 default: | ||||
|                     let chatMessage = viewModel.messages[index] as! LiveRoomNormalChat | ||||
|                     LiveRoomChatItemView( | ||||
|                         chatMessage: chatMessage, | ||||
|                         onClickProfile: { | ||||
|                             if chatMessage.userId != UserDefaults.int(forKey: .userId) { | ||||
|                                 viewModel.getUserProfile(userId: chatMessage.userId) | ||||
|                             } | ||||
|                         } | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         .rotationEffect(Angle(degrees: 180)) | ||||
|         .valueChanged(value: viewModel.messageChangeFlag) { _ in | ||||
|             if viewModel.offset - viewModel.originOffset > (56.7 * 2) { | ||||
|                 isShowingNewChat = true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct LiveRoomView_Previews: PreviewProvider { | ||||
|     static var previews: some View { | ||||
|         LiveRoomView() | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct AttributedTextView: UIViewRepresentable { | ||||
|     let attributedString: NSAttributedString | ||||
|     let lineLimit: Int | ||||
|     let onURLTapped: (URL) -> Void | ||||
|      | ||||
|      | ||||
|     func makeUIView(context: Context) -> UITextView { | ||||
|         let textView = UITextView() | ||||
|         textView.isEditable = false | ||||
|         textView.isSelectable = false | ||||
|         textView.isScrollEnabled = false | ||||
|         textView.backgroundColor = .clear | ||||
|         textView.font = UIFont(name: Font.light.rawValue, size: 11.3) | ||||
|         textView.textColor = .white | ||||
|         textView.textContainer.lineFragmentPadding = 0 | ||||
|         textView.textContainerInset = .zero | ||||
|         textView.textContainer.maximumNumberOfLines = lineLimit | ||||
|         textView.textContainer.lineBreakMode = lineLimit == 1 ? .byTruncatingTail : .byWordWrapping | ||||
|         textView.delegate = context.coordinator | ||||
|          | ||||
|         textView.isUserInteractionEnabled = true | ||||
|          | ||||
|         // Add tap gesture recognizer to handle URL tap events | ||||
|         let tapGestureRecognizer = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTapGesture(_:))) | ||||
|         textView.addGestureRecognizer(tapGestureRecognizer) | ||||
|  | ||||
|          | ||||
|         return textView | ||||
|     } | ||||
|      | ||||
|     func updateUIView(_ uiView: UITextView, context: Context) { | ||||
|         uiView.attributedText = attributedString | ||||
|         uiView.textColor = UIColor.white | ||||
|         uiView.textContainer.maximumNumberOfLines = lineLimit | ||||
|         uiView.textContainer.lineBreakMode = lineLimit == 1 ? .byTruncatingTail : .byWordWrapping | ||||
|     } | ||||
|      | ||||
|     func makeCoordinator() -> Coordinator { | ||||
|         Coordinator(onURLTapped: onURLTapped) | ||||
|     } | ||||
|      | ||||
|     class Coordinator: NSObject, UITextViewDelegate { | ||||
|         let onURLTapped: (URL) -> Void | ||||
|         let linkAttributeName = NSAttributedString.Key.link.rawValue | ||||
|          | ||||
|         init(onURLTapped: @escaping (URL) -> Void) { | ||||
|             self.onURLTapped = onURLTapped | ||||
|         } | ||||
|          | ||||
|         @objc func handleTapGesture(_ gesture: UITapGestureRecognizer) { | ||||
|             let textView = gesture.view as? UITextView | ||||
|             let location = gesture.location(in: textView) | ||||
|              | ||||
|             let layoutManager = textView?.layoutManager | ||||
|             let characterIndex = layoutManager?.characterIndex(for: location, in: textView!.textContainer, fractionOfDistanceBetweenInsertionPoints: nil) | ||||
|              | ||||
|             if characterIndex != NSNotFound { | ||||
|                 let attributedString = textView?.attributedText | ||||
|                  | ||||
|                 attributedString?.enumerateAttribute(NSAttributedString.Key(rawValue: linkAttributeName), in: NSRange(location: 0, length: attributedString!.length), options: []) { value, range, _ in | ||||
|                     if let url = value as? URL, NSLocationInRange(characterIndex!, range) { | ||||
|                         onURLTapped(url) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										1420
									
								
								SodaLive/Sources/Live/Room/LiveRoomViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,13 @@ | ||||
| // | ||||
| //  SetManagerOrSpeakerOrAudienceRequest.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/14. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct SetManagerOrSpeakerOrAudienceRequest: Encodable { | ||||
|     let roomId: Int | ||||
|     let memberId: Int | ||||
| } | ||||
| @@ -14,6 +14,7 @@ struct HomeView: View { | ||||
|      | ||||
|     @StateObject var viewModel = HomeViewModel() | ||||
|     @StateObject var appState = AppState.shared | ||||
|     @StateObject var contentPlayManager = ContentPlayManager.shared | ||||
|      | ||||
|     private let liveView = LiveView() | ||||
|     private let explorer = ExplorerView() | ||||
| @@ -49,6 +50,57 @@ struct HomeView: View { | ||||
|                      | ||||
|                     Spacer() | ||||
|                      | ||||
|                     if contentPlayManager.isShowingMiniPlayer { | ||||
|                         HStack(spacing: 0) { | ||||
|                             KFImage(URL(string: contentPlayManager.coverImage)) | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 36.7, height: 36.7) | ||||
|                                 .cornerRadius(5.3) | ||||
|                              | ||||
|                             VStack(alignment: .leading, spacing: 2.3) { | ||||
|                                 Text(contentPlayManager.title) | ||||
|                                     .font(.custom(Font.medium.rawValue, size: 13)) | ||||
|                                     .foregroundColor(Color(hex: "eeeeee")) | ||||
|                                     .lineLimit(2) | ||||
|                                  | ||||
|                                 Text(contentPlayManager.nickname) | ||||
|                                     .font(.custom(Font.medium.rawValue, size: 11)) | ||||
|                                     .foregroundColor(Color(hex: "d2d2d2")) | ||||
|                             } | ||||
|                             .padding(.horizontal, 10.7) | ||||
|                              | ||||
|                             Spacer() | ||||
|                              | ||||
|                             Image(contentPlayManager.isPlaying ? "ic_noti_pause" : "btn_bar_play") | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 25, height: 25) | ||||
|                                 .onTapGesture { | ||||
|                                     if contentPlayManager.isPlaying { | ||||
|                                         contentPlayManager.pauseAudio() | ||||
|                                     } else { | ||||
|                                         contentPlayManager | ||||
|                                             .playAudio(contentId: contentPlayManager.contentId) | ||||
|                                     } | ||||
|                                 } | ||||
|                              | ||||
|                             Image("ic_noti_stop") | ||||
|                                 .resizable() | ||||
|                                 .frame(width: 25, height: 25) | ||||
|                                 .padding(.leading, 16) | ||||
|                                 .onTapGesture { contentPlayManager.stopAudio() } | ||||
|                         } | ||||
|                         .padding(.vertical, 10.7) | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                         .background(Color(hex: "222222")) | ||||
|                         .contentShape(Rectangle()) | ||||
|                         .onTapGesture { | ||||
|                             appState | ||||
|                                 .setAppStep( | ||||
|                                     step: .contentDetail(contentId: contentPlayManager.contentId) | ||||
|                                 ) | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|                     BottomTabView(width: proxy.size.width, currentTab: $viewModel.currentTab) | ||||
|                      | ||||
|                     if proxy.safeAreaInsets.bottom > 0 { | ||||
| @@ -63,6 +115,10 @@ struct HomeView: View { | ||||
|                     viewModel.getEventPopup() | ||||
|                 } | ||||
|                  | ||||
|                 if appState.isShowPlayer { | ||||
|                     LiveRoomView() | ||||
|                 } | ||||
|                  | ||||
|                 if appState.isShowNotificationSettingsDialog { | ||||
|                     NotificationSettingsDialog() | ||||
|                 } | ||||
|   | ||||
| @@ -37,6 +37,10 @@ final class UserRepository { | ||||
|         return api.requestPublisher(.getMemberInfo) | ||||
|     } | ||||
|      | ||||
|     func getMemberCan() -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getMemberInfo) | ||||
|     } | ||||
|      | ||||
|     func updateNotificationSettings(live: Bool? = nil, uploadContent: Bool? = nil, message: Bool? = nil) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher( | ||||
|             .notification( | ||||
|   | ||||
 Yu Sung
					Yu Sung