From 4daddba6ba5478026cec16a2c2b25f57aa81b76f Mon Sep 17 00:00:00 2001 From: linshujie Date: Fri, 21 Aug 2026 16:47:33 +0800 Subject: [PATCH] =?UTF-8?q?-=20=E6=89=AB=E4=B8=80=E6=89=AB=E3=80=81?= =?UTF-8?q?=E6=8B=8D=E7=85=A7=20-=20=E7=9B=B8=E5=86=8C=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- QuickLocation.xcodeproj/project.pbxproj | 44 + .../Assets.xcassets/Camera/Contents.json | 9 + .../Camera/btn_album.imageset/Contents.json | 8 + .../btn_album.imageset/btn_flash@2x.png | Bin 0 -> 1423 bytes .../btn_album.imageset/btn_flash@3x.png | Bin 0 -> 2831 bytes .../Camera/btn_flash.imageset/Contents.json | 8 + .../Camera/btn_flash.imageset/btn_flip@2x.png | Bin 0 -> 1185 bytes .../Camera/btn_flash.imageset/btn_flip@3x.png | Bin 0 -> 2144 bytes .../Camera/btn_flip.imageset/Contents.json | 8 + .../Camera/btn_flip.imageset/btn_album@2x.png | Bin 0 -> 1600 bytes .../Camera/btn_flip.imageset/btn_album@3x.png | Bin 0 -> 3086 bytes .../Camera/btn_mute.imageset/Contents.json | 8 + .../Camera/btn_mute.imageset/btn_mute@2x.png | Bin 0 -> 1372 bytes .../Camera/btn_mute.imageset/btn_mute@3x.png | Bin 0 -> 2729 bytes .../Camera/scan_frame.imageset/Contents.json | 8 + .../scan_frame.imageset/scan_frame@2x.png | Bin 0 -> 4235 bytes .../scan_frame.imageset/scan_frame@3x.png | Bin 0 -> 9726 bytes .../Assets.xcassets/PhotoPicker/Contents.json | 9 + .../btn_album_arrow.imageset/Contents.json | 8 + .../btn_album_arrow@2x.png | Bin 0 -> 556 bytes .../btn_album_arrow@3x.png | Bin 0 -> 1074 bytes .../btn_close.imageset/Contents.json | 8 + .../btn_close.imageset/btn_close@2x.png | Bin 0 -> 609 bytes .../btn_close.imageset/btn_close@3x.png | Bin 0 -> 1241 bytes .../CameraCapture/CameraCaptureVC.swift | 71 ++ .../CameraCapture/CameraCaptureView.swift | 763 ++++++++++++++++++ .../PhotoPicker/PhotoPickerAlbumView.swift | 112 +++ .../PhotoPicker/PhotoPickerPreviewVC.swift | 163 ++++ .../PhotoPicker/PhotoPickerStore.swift | 169 ++++ .../Component/PhotoPicker/PhotoPickerVC.swift | 121 +++ .../PhotoPicker/PhotoPickerView.swift | 389 +++++++++ .../Section/Group/GroupChat/GroupChatVC.swift | 88 +- .../Group/GroupChat/GroupChatView.swift | 246 +++--- .../Group/GroupChat/GroupChatViewModel.swift | 64 +- QuickLocation/Section/Group/GroupView.swift | 128 ++- .../Section/Group/GroupViewModel.swift | 30 + .../Section/Home/HomeViewController.swift | 32 +- .../Section/Map/MemberAnnotationView.swift | 89 +- .../Section/Mine/Feedback/FeedbackVC.swift | 44 +- .../PigeonMessage/PigeonMessageVC.swift | 19 +- QuickLocation/Section/Scan/ScanVC.swift | 201 ++--- 41 files changed, 2428 insertions(+), 419 deletions(-) create mode 100644 QuickLocation/Assets.xcassets/Camera/Contents.json create mode 100644 QuickLocation/Assets.xcassets/Camera/btn_album.imageset/Contents.json create mode 100644 QuickLocation/Assets.xcassets/Camera/btn_album.imageset/btn_flash@2x.png create mode 100644 QuickLocation/Assets.xcassets/Camera/btn_album.imageset/btn_flash@3x.png create mode 100644 QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/Contents.json create mode 100644 QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/btn_flip@2x.png create mode 100644 QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/btn_flip@3x.png create mode 100644 QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/Contents.json create mode 100644 QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/btn_album@2x.png create mode 100644 QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/btn_album@3x.png create mode 100644 QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/Contents.json create mode 100644 QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/btn_mute@2x.png create mode 100644 QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/btn_mute@3x.png create mode 100644 QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/Contents.json create mode 100644 QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/scan_frame@2x.png create mode 100644 QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/scan_frame@3x.png create mode 100644 QuickLocation/Assets.xcassets/PhotoPicker/Contents.json create mode 100644 QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/Contents.json create mode 100644 QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@2x.png create mode 100644 QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@3x.png create mode 100644 QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/Contents.json create mode 100644 QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/btn_close@2x.png create mode 100644 QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/btn_close@3x.png create mode 100644 QuickLocation/Component/CameraCapture/CameraCaptureVC.swift create mode 100644 QuickLocation/Component/CameraCapture/CameraCaptureView.swift create mode 100644 QuickLocation/Component/PhotoPicker/PhotoPickerAlbumView.swift create mode 100644 QuickLocation/Component/PhotoPicker/PhotoPickerPreviewVC.swift create mode 100644 QuickLocation/Component/PhotoPicker/PhotoPickerStore.swift create mode 100644 QuickLocation/Component/PhotoPicker/PhotoPickerVC.swift create mode 100644 QuickLocation/Component/PhotoPicker/PhotoPickerView.swift diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index 89aa7cca..1ea7fde2 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -162,6 +162,13 @@ 305A79902FCAC61A00227D26 /* InviteMemberVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A798F2FCAC61A00227D26 /* InviteMemberVC.swift */; }; 3062E8B52FCE6BBA00CEF511 /* ScanVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8B42FCE6BBA00CEF511 /* ScanVC.swift */; }; 3062E8B72FCE6BFE00CEF511 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8B62FCE6BFE00CEF511 /* ScanView.swift */; }; + 30A1C0A12FEB000100CA0001 /* CameraCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A1C0A02FEB000100CA0001 /* CameraCaptureView.swift */; }; + 30A1C0A32FEB000100CA0001 /* CameraCaptureVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A1C0A22FEB000100CA0001 /* CameraCaptureVC.swift */; }; + 30B2D0B12FEB111100CA0001 /* PhotoPickerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B2D0B02FEB111100CA0001 /* PhotoPickerStore.swift */; }; + 30B2D0B32FEB111100CA0001 /* PhotoPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B2D0B22FEB111100CA0001 /* PhotoPickerView.swift */; }; + 30B2D0B52FEB111100CA0001 /* PhotoPickerAlbumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B2D0B42FEB111100CA0001 /* PhotoPickerAlbumView.swift */; }; + 30B2D0B72FEB111100CA0001 /* PhotoPickerPreviewVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B2D0B62FEB111100CA0001 /* PhotoPickerPreviewVC.swift */; }; + 30B2D0B92FEB111100CA0001 /* PhotoPickerVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B2D0B82FEB111100CA0001 /* PhotoPickerVC.swift */; }; 3062E8BA2FCEAC6500CEF511 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8B92FCEAC6500CEF511 /* CreateGroupView.swift */; }; 3062E8BC2FCEAC7100CEF511 /* CreateGroupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8BB2FCEAC7100CEF511 /* CreateGroupVC.swift */; }; 3062E8BE2FCEBD0E00CEF511 /* GroupIconListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8BD2FCEBD0E00CEF511 /* GroupIconListVC.swift */; }; @@ -580,6 +587,13 @@ 305A798F2FCAC61A00227D26 /* InviteMemberVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteMemberVC.swift; sourceTree = ""; }; 3062E8B42FCE6BBA00CEF511 /* ScanVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanVC.swift; sourceTree = ""; }; 3062E8B62FCE6BFE00CEF511 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = ""; }; + 30A1C0A02FEB000100CA0001 /* CameraCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraCaptureView.swift; sourceTree = ""; }; + 30A1C0A22FEB000100CA0001 /* CameraCaptureVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraCaptureVC.swift; sourceTree = ""; }; + 30B2D0B02FEB111100CA0001 /* PhotoPickerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerStore.swift; sourceTree = ""; }; + 30B2D0B22FEB111100CA0001 /* PhotoPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerView.swift; sourceTree = ""; }; + 30B2D0B42FEB111100CA0001 /* PhotoPickerAlbumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerAlbumView.swift; sourceTree = ""; }; + 30B2D0B62FEB111100CA0001 /* PhotoPickerPreviewVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerPreviewVC.swift; sourceTree = ""; }; + 30B2D0B82FEB111100CA0001 /* PhotoPickerVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerVC.swift; sourceTree = ""; }; 3062E8B92FCEAC6500CEF511 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = ""; }; 3062E8BB2FCEAC7100CEF511 /* CreateGroupVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupVC.swift; sourceTree = ""; }; 3062E8BD2FCEBD0E00CEF511 /* GroupIconListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupIconListVC.swift; sourceTree = ""; }; @@ -963,6 +977,8 @@ 305A74E72FCA8C7000227D26 /* Component */ = { isa = PBXGroup; children = ( + 30A1C0A42FEB000100CA0001 /* CameraCapture */, + 30B2D0BA2FEB111100CA0001 /* PhotoPicker */, 305A74D72FCA8C7000227D26 /* AddImageCell */, 305A74DB2FCA8C7000227D26 /* CornerRadiusView */, 305A74E22FCA8C7000227D26 /* ImagePicker */, @@ -971,6 +987,27 @@ path = Component; sourceTree = ""; }; + 30A1C0A42FEB000100CA0001 /* CameraCapture */ = { + isa = PBXGroup; + children = ( + 30A1C0A02FEB000100CA0001 /* CameraCaptureView.swift */, + 30A1C0A22FEB000100CA0001 /* CameraCaptureVC.swift */, + ); + path = CameraCapture; + sourceTree = ""; + }; + 30B2D0BA2FEB111100CA0001 /* PhotoPicker */ = { + isa = PBXGroup; + children = ( + 30B2D0B02FEB111100CA0001 /* PhotoPickerStore.swift */, + 30B2D0B22FEB111100CA0001 /* PhotoPickerView.swift */, + 30B2D0B42FEB111100CA0001 /* PhotoPickerAlbumView.swift */, + 30B2D0B62FEB111100CA0001 /* PhotoPickerPreviewVC.swift */, + 30B2D0B82FEB111100CA0001 /* PhotoPickerVC.swift */, + ); + path = PhotoPicker; + sourceTree = ""; + }; 305A74EA2FCA8C7000227D26 /* RTL */ = { isa = PBXGroup; children = ( @@ -2538,6 +2575,13 @@ 305A76E52FCA8C7000227D26 /* HomeViewController.swift in Sources */, 305A76E62FCA8C7000227D26 /* LaunchViewController.swift in Sources */, 3062E8B52FCE6BBA00CEF511 /* ScanVC.swift in Sources */, + 30A1C0A12FEB000100CA0001 /* CameraCaptureView.swift in Sources */, + 30A1C0A32FEB000100CA0001 /* CameraCaptureVC.swift in Sources */, + 30B2D0B12FEB111100CA0001 /* PhotoPickerStore.swift in Sources */, + 30B2D0B32FEB111100CA0001 /* PhotoPickerView.swift in Sources */, + 30B2D0B52FEB111100CA0001 /* PhotoPickerAlbumView.swift in Sources */, + 30B2D0B72FEB111100CA0001 /* PhotoPickerPreviewVC.swift in Sources */, + 30B2D0B92FEB111100CA0001 /* PhotoPickerVC.swift in Sources */, 305A76E72FCA8C7000227D26 /* LoginView.swift in Sources */, 305A76E82FCA8C7000227D26 /* LoginViewController.swift in Sources */, 30EFF3DC2FDA981B00EB35D4 /* EmergencyContactAddView.swift in Sources */, diff --git a/QuickLocation/Assets.xcassets/Camera/Contents.json b/QuickLocation/Assets.xcassets/Camera/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Camera/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/Contents.json b/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/Contents.json new file mode 100644 index 00000000..20cb433a --- /dev/null +++ b/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "btn_flash@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "btn_flash@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/btn_flash@2x.png b/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/btn_flash@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ed8053aa0ea53c53bd6d2dd9318fae95323af99d GIT binary patch literal 1423 zcmeAS@N?(olHy`uVBq!ia0vp^DImqfrMIA zWleokYyY&FbLKBvx^m6>O*;-9J#pszrK>k?-?{(j>5JFzKYji2J5>H7FyxsZd%8G= zWZc_3D>M3*gTQfR4XvUUZ7$Cn386Mxx)oDaadN!vpg;)+h| zD^9tg8uM1oscx6%QsF6Iu57w1c2a~h>gXAx857>awo2C>= zR~+ken>J-zv&)*Lo%~C$8}yX6{+P7sTzZaJhrg&#@I8}Sg z{sh;T`~QT3!WjeY-%T}&Dc*nU1@C>`RrlKOy5-8(h5vhC-0=HnVRlYo%C=Jv_Pzh6 zXXLOnXm0n6gjZ}I8hQ3BF8%gK`|xwV(7zKc*Iv*ooIPpLbpy4Y!p(Tp`A69t@^AAR@)u5Et`_QRoH*M}%(E)-?cw`V-?P=Tl|N|8xHWb4mqkxln2uQR z{@#14$s$(&F*ifdMs8_`DJGFB2dZOtZ+~VRVEFv>JDC^JyVtPSXdK$G-}$uES@AQc zlM4BHHhi0q*sVA@>GqL-Z$4g0bvpNA>-^m8lLopIx|8ZR7+3Epa@2dd)w)dZ$&MQz zEgw4T#ZJ&ZFCa8+5yy#{+(oZsg{D5@=rD{hh~s%b!D5g4E`=vGhOOF1tX-yUlHk}m zL-&|eB-5m+A|fs6VKW3=Pd!$!*{D$I8Efk@^~+V~l9fBEJT96)d6Jm2&y4@3!2G+V zwsUXiE2owJl&W}q?d{`(%)K#CHiW@{wNC%v2=x%`wpwe z%a~30sH%8^t%@DgBLK z&)<{zrUs*UL+Z zxzC%GH0@@irbyEN4@?hRZv<>=*z&Pw+d)sU50~X?*zDgj&JuepWvchMs&oE<#3hSP z{Rz3UqqN7%)o^mweW|(E%{5kD%MRJ-X=?b=QL)p{P&3-_WV&Kzi6J{vQlD9i%%u*? zJfY$OwPzdwXA+mqNRBjQKaZr^w?p^y7o+-60&{j>-JI=A|nevN6lNPKFU!*sY dwLtcr{>A*mrJ^}9O~3+$!PC{xWt~$(695bG7CZm| literal 0 HcmV?d00001 diff --git a/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/btn_flash@3x.png b/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/btn_flash@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..54b73456c67a9e0817825609b3ca15db6aff8893 GIT binary patch literal 2831 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 literal 0 HcmV?d00001 diff --git a/QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/btn_flip@3x.png b/QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/btn_flip@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ade5b67fd899fc31013b004aac8f9e5db312398 GIT binary patch literal 2144 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 literal 0 HcmV?d00001 diff --git a/QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/btn_album@3x.png b/QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/btn_album@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..acef7152d8d5c2f5913563e503ee7001cd7badc6 GIT binary patch literal 3086 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 literal 0 HcmV?d00001 diff --git a/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/Contents.json b/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/Contents.json new file mode 100644 index 00000000..23426939 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "btn_mute@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "btn_mute@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/btn_mute@2x.png b/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/btn_mute@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..aaaed8e82e2d4180ba3c528602917ca058b35c86 GIT binary patch literal 1372 zcmeAS@N?(olHy`uVBq!ia0vp^DIm*}o*n zFPMRmnT3^&or8;qS4c!mTv}F6UQt<1LrYsnSKq+U$k@!%#@^Y@(q) ztg^nRf6~;Mv*#>YvuVfvLq|`XzH|T4(-*JbfBo@$azv^T0|WCJPZ!6KjC)6CRY%`4 z5ILr;k@Yl4Lqt<|p@r*>HorM1_AFSz;dkcye=}{}>2Z@MUr?|9zu5oG`DOWj=RB1> z9hN?w6Lc-Bbn``n*QRUNJbAKZ$`Yf>-R5G;wYxG`sBAuAog=xm@j%uJn;BCMH53?deO{3Ix+C(dj&RcYrX3+Sn~nQ;j%YsS=(wS| zP&edOqd}SJmZ@7}t&-#2qrL7gH{|2pvbD$A$k{BA$td+vwaTFlnl0rw%>$f_lJ=io z*C#Yt?(s%hUR4d)@B?*B&fn&Jc@; z5Aw+lsqXsd&b^`Q=XI~v*1|MngMAEJLTW6TPgmWmiZ=Mbn{xMinz+{K?-JHLHZ3M$ zj{28)Y%7oO2%KLnk}S;|zvjSX>oZJ_N4IW$#I(os(2tA%Ur23sJTvv+r$XI-I##>C z+P$nbbPA~B>^!@2zh&Itt(6yk9$7Qt)h08AGrRVe<$socJ>U2Yk7HhHpu(0PmfLs# z&e(15r#6E*s6gR})bjl=-v2$a@gVoJMj7kLEqBs(ytrWa>1GdilTpJD>!>d;B!2#$ z!##<$MWlR+jBeEUmgLrqT4p974JnVxK0Zb?*%HqBJnG=+JK z)!enccdF9cpD1K6`ueKuwM3=u>xTlaTEVwX=7;~^bN)nuN93aC%f8R(<gPB|1Rs}|*^a5dWM*k(t9t5_(=|&Yg`KAhoTphDsrag@ z-`P0f<*gGFT4qOB9dS}qpDiO9WF=GJkd~X@$D`4-M9cWeX5r8`6Ho2@ama-4)75UR zZzozaPo=cPxQkX#5t_XrQh;+Ow@9__i8RK7|7kCHd*`J)Kj7;-#UT0n*|Cg!?0F9k z#63NhxaTMP@mEdT7dgCMJbAv)`G{@zMV`Gqc&+_x#%2Si+sz9i?o>)d={|vE{qPwC#*E|Lm84RATelF{r5}E+C0qvgv literal 0 HcmV?d00001 diff --git a/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/btn_mute@3x.png b/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/btn_mute@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..3f6226c84ee410802112b17ff664de26fc7ea36d GIT binary patch literal 2729 zcmeHJ`8yMiAD?3;N4ZHEa+G6kA32t2S~FK;j#ZjmTP&#|M^~qaE3c?@Q2DS5SWQDyOZ(_C9bG+rh=HMzv5Bdf`Ck^tEv>9=peJnY>`%cQPQ%Y2 zkWS7nu5M>h?jC4QZy(J03%=MO96pqACH&fTVnkF-Y~0QGgrqwu=^2@K@8yyU9~M8O zlu%2{$}1{qFRS0YeOKSm*xd5Dt)q+ewTIo?*UuRkKcE2V~W6>u2poE|=D!X!Kza=W+-~_Y#4g(XZ>aA^_V^)eneLe-zq0+8$~GZ3_JU zsOxuR@Dek4sc7=8KDOGMuz~tGziKAje8m3J@t%or%VbnT_hsYoXgj}s%U7d?#6wZ) zB8>*1izsr43$0`9{ZoSy;VX8<4RPofN`+!S<<~_It;=r+PB)d8QhQ}jtKEU-Ybl&U z6h<#Q7Q5_TkBP4FyZBXGSjt%Nc6VABUN>^n&2z;vqf)j84RRY0^%$whsTNa*0o6Y` zhOPe{afBlLI=h^qOB(fqN&7`f15v;@sk5i=8MejjaMXm{a%UgT?6gU3{ti{Z%9}`_ zTadlJddhI*^V;0km_>L>bJN3s>Vo8ZA4T&MkG@BU9{D6Km(bY7GU^a$c5!R;4^KOG zR7wTl6%cvnY_?P+w#hKp9cG!tGn-^>7QJMD!CKHErvMLNQ4!@QlsNq@yfEL4gPf~z-zaqAaGPjPLe|9SLy^_D zDqQN;KXaF>98xbLbR(sfI<>elxk78!rD4NY(K*$n$%lsr13!%@61Hcuh|Y__LC1Pl z@xcWNgCQ&2MO=NEq6`porbemzxGC**2+r>V?GUk>7mlFV>tR#2c=RTHm-)gzf>r!V zkE|m@23q%-)HezJZ3^ABsm1@&NLPR?R|o5V6jK>~PiprEXKL2+vl0C`Ao9WUQ(8~P zxvRN0bJI65zEBIT7)FaD=d|1nqXwcxYwP1(q<@RGLF=(zd$74vtv}o>EURN_b})N3 zIE00iamM(e%{(XIPqx9GpL~CyRBPjP*rmz3a+rkkmuJ$ZNKHN(wm2}k%$erfZk=Q$ zdE)L}>+SICf43ktA2VmR5r&zTjZf#3Q6b+c}aOt)k|8XTgZKDG3u|x z01;44*#!%#nHvngfY_Paeo8>zPx}2hw`Lw!Nhz6)9h+M?WGD0yM`$$I+nN=>|G8PL ziC_tEEA7#8d3pX>;wbO7rNdC(IPs_uU4r5tr|9PMNdmU^0jBLc4{Pm%_OyxMT+SUh zFsAHmmX8^!GPEkYYc7B{PbhMY4UVGtb6?&xtfx$S=m}b@QrzMXS~D0#SZkM$jMpO7 zQ4dXQiT8%f42cr)rD@rdS13lW@=PFf9$7U#2iP5X-vpAyd;2EW?b47S1Fw~q-Tp~B z$M%9$K)|ZGEIp7N{;b&hJ?1 zTsh9jF;$r$ysSx>!B0A^`d4=5f$l?i2_CuYRJ z^mmHNYylYbxO_5Vfp&e^2_zvu(V_yTuZ8RlQ;YUXEwg-$`)m+J4YE=bxGrF%lL=o4 z`Z~2nbpm~8G1N+~G3M(qzRX;r&NzX(T26`We}#h%)9W;Yt*$01i)YDnXkuLVLmHa* z_}Vvc#^U?wFw(ue`419K-NL1|dOb1573g|fJ&M0-Jh0>>>R5i^Kvy9Z&xnBS7jwm) z<52B&rj#O5T||p}l)sw~iDV%0a+8q081v^E`+nX1YI{PE?DIL#QQ-|OR&s4HhF{m| zI+}c^Qb&tK1wLW^V6ATYjzy1+0u)V+snb48#{>>Z(VJOzn}dXsnS#k2 zaer^7Zz{JlO^I2b&LzDdGZiv9$bK^OS~gdF%bUr%$L&<*GIevgB!@y~O8zV|B!3w{ cB;T|v5E*EK1Bv1D_I?Tg!odah!XA73U-3OF_y7O^ literal 0 HcmV?d00001 diff --git a/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/Contents.json b/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/Contents.json new file mode 100644 index 00000000..6674d71c --- /dev/null +++ b/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "scan_frame@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "scan_frame@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/scan_frame@2x.png b/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/scan_frame@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..64399c5d34c33518d0649d033c95f100be3d8e02 GIT binary patch literal 4235 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 literal 0 HcmV?d00001 diff --git a/QuickLocation/Assets.xcassets/PhotoPicker/Contents.json b/QuickLocation/Assets.xcassets/PhotoPicker/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PhotoPicker/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/Contents.json b/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/Contents.json new file mode 100644 index 00000000..4d8797c4 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "btn_album_arrow@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "btn_album_arrow@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@2x.png b/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..52d631b183e2f34105e6bd485c6db01ac7f41c2d GIT binary patch literal 556 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-s)d4;su0Xn>p)$6yPzkL7k^Vjdc|BhUHRfmCr z(bdz%F~sBe-Dx+=niWJ`4@%BS7s#G{?bz@C@i&$;u?gPZ^K?(Z5$6&z0bGiOYB zYf*SDMCFj5)dJg$ml+j(%Vp$@MEA}0XS3k&*4Z(=QT6A;pgmG+R@zQJuKs7?#KW0J z$$KB1IDD%1nNVATy2t~cRi7U|5!>;PgG=0LYxu2L!75dk0?mygCrleX76zH`zn?#Q z*R8z#^3qVRg@NY#TOyWh)OD&^bw5foBY5}uQ#ZsE)81At7WuM1ZR)XY9ER(EZC7c% zd!vrGnO(0{(C6@zM$d(;mkla2mIO3q&aesjc5v-y#`U~Ro7owXOUkMrEaVw2SW3K;GTp00i_>zopr E0RG|wDgXcg literal 0 HcmV?d00001 diff --git a/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@3x.png b/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..50dcbaf3a5fa606bb39bca61e0ab2bdee10e9fbb GIT binary patch literal 1074 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`ol6-Q7`X#{LR^8||Ns9tG&HPQwQA zgbO4g450YRl`A1^pfZR`2nmz|3oJ*L1&YAAKn9!*7uVD^($X{5F)-CPwlFZZFtfC? zb#S$FbanOc@$w1q_6zj(3-SvL2@DAji-?X*NKVVh&Mzz}C@Lu~DK9Onm^@|b)M?YF zO`kDm&fGzuQ z<7U|{1A(@~`;Icst~IwdW7~X_t^I=PQr{Po#JpC0ub;F_@vY;g6qVR7O6vcgdi?AA zU+gwZGB#$G8-=B7=xcrrwlCbN}HJ<{d;Ox+&0@~`UeRv*Rs#){o zFwc|~H;xAMMXlJ$p{h0^>s&*7TCYz7SI8Be2P>bhD46%i`wBzrtB=JCQqC`7oH69Od*@trpL`$&A8y2!b|0%N3z{=DPM(L0`9W#7-cW{qsibV`tj!!$&F20MEZm? zbs`SDVIfo|`P zh&>3gW@}{W6<@+B;9*qhxQwMy>&36{{I7S*O5M4;_Wpl|W>pRq<<l1Ha$fOUmizx)wyhHIaIgRI`P9!7`)n28=KXgGyMJM> zbN+?I>SX74^J;FI-)7!b%++|OwRC^%++EhcqH@2#yTsjSp#H6({?WUe*Z(q3zAcc^ zb#j7mxl4Rs35Us%r?>3OQ{wdm{)PR1-S_5n;C*AoM4-UB79ByqOBZEtPG8J*cVTUr z`=_T?Tb^V;IVpQ_`fI7qt)1VG{Chq7>vY?~ovl;0Z8}&kdv>+Q={fU0G#<<7J5M~k^>^9PQ;}vHoE^G$ZtFGrac%txF0Bb}ol?=p zQR-z|JLSBq?Q?o;tTT=&)9jU&C|#RXo|y-#+2)QDTocvWcMXXWU`l4TFKZBe?b zF)Op8f6>{HhN4xyPePJ5=ad}PI6O!8oOZHLb(YT*o!1ZKg;wi+VO$vN$8LE~=}(~J ssuQ)b=3F>ztRLo7AM)-v_aZ0p`0>ZfPje_uN;~A0`9k`6hIOW! zMQrfo2=*P3J_?b}Xm;3SC?Z;nAG)$%~ zRX+CN*ZNgF2{k8$e;g9%{@QQKaAeh#;~IzJzHz<^7w3HJF)f$b;VKK$1)Z<1m3^}w zBrGUCTkF%{_c+0pX&WbFmaG%6z(!tHjZzL#hQ!$-3eTgbbsv8!ZOZO(eN(E;zQ31> zGc1p_33KY@sOma++-H6G`Ps~m2R}!x=irIRv6pI*tUBP$(NlQfG>36=`~UDmv7Y74 z`(|I;-CBI)TESG7-en%o9n*FPC(6a;sGe;N?e|TJud(`}c(>kaf`&oeZ^qB;XD+!W+DzB)0LCDL Mr>mdKI;Vst04@JR2><{9 literal 0 HcmV?d00001 diff --git a/QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/btn_close@3x.png b/QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/btn_close@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..f890703cbeb7c69c3e7fe81e245fcabb324c57ac GIT binary patch literal 1241 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAk#{{Wv5R}ejd!SAgX{s4m@q$J2M zn1PXrnT3^|i-(tAP)I~VQd&kr*~QZ5SW9uN-*V99diij~Fv8Vq)d*P*|Xl7qEH`_m=y=?N9I9;L7)QS#V@_{0wcQE%oX% zRVLf(U0*MBzbxbbhaW0QO6tems~76d=M^ab;G}iszTK@_Ug3+sVt;Op+F`}N`+u+S z{{L@!Beb&I_2x${u1G$wwavA%qvC8KUmhEW=WDB+HsK1RJ(n_{EKO0$diEycQP8RV z67n+F%ft%H{?+9Eur1&AC7>p~d=t}w`3L^gKa^^?=EIun+F?B>D~rLOy|`|l9CKFT z^|~!@@AWafO}qAZqL_i!ua-yKRxmZ+IP-{k!QEdh#tT+3p1H#<#at`Zmi>cx6)PRJbN1Qp*s z@eFKs?n_rQi}ZwVUv;2+v(gQQSGRWwRk40L*YN2;v`6^26o(=<6-N2ryNa%`>dj?T zZTz-yoy_;fBZrK$rRUvj{o1>=wOz>Je?+*SiNY>{oSR-%#Z`&MpJ&8s7zH|58wSZM z810|GrYUGvoTjG6&7Zbs_hfTz`N$FQNqX0QmVlcAY*FVm4j3#~{#oqo;91?$7&BY7 zVPRfuCP%=nl1^sdxNp*Byx!gCZ!rBbN}uuBzs`bXR?d^^Y`#XG*K6JgG5j(*bxMy> z;jR_;?tknTPWePn+#Fu|N1OYE=+YCbCS99srmZ})^y8s0L+zaYN3CZ)S8mn%*FW>- zqxtJ}H2+PzesPYo&FOWE51UOWDRYctx~m*}^Z$>VQ+3}<`X0G`epW~L;?5iEn0eD3 z^c_mGcU^pCk-dfOa_=;6slQ9U2Cr9p<;s7;@<8Ez#<`#0-}udPulbR~=i05m|Ecp< zSFT#$xB8rBi;;8Qe8#mJURimj4-(UK%@{%gXPGiZZB9L0y_j)vwBV!gkj9T+Wv<#) zTw0lNLr#_RRmhfY4y{a9mebz9neh0|Zoj^>8D+U^?zc8i-&2z5dVO-xzKe!zGX9L0 zU+l{3 Void)? + var onPhoto: ((UIImage) -> Void)? + var onVideo: ((URL) -> Void)? + var onOpenAlbum: (() -> Void)? + + init(mode: CameraCaptureMode) { + self.captureMode = mode + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + rootView = CameraCaptureView(mode: captureMode) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + rootView.onClose = { [weak self] in + self?.closeCapture() + } + rootView.onScan = { [weak self] text in + self?.onScan?(text) + } + rootView.onPhoto = { [weak self] image in + self?.onPhoto?(image) + } + rootView.onVideo = { [weak self] url in + self?.onVideo?(url) + } + rootView.onOpenAlbum = { [weak self] in + self?.onOpenAlbum?() + } + } + + private func closeCapture() { + if presentingViewController != nil { + dismiss(animated: true) + } else { + navigationController?.popViewController(animated: true) + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + rootView.startSession() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + rootView.stopSession() + } +} diff --git a/QuickLocation/Component/CameraCapture/CameraCaptureView.swift b/QuickLocation/Component/CameraCapture/CameraCaptureView.swift new file mode 100644 index 00000000..aa346164 --- /dev/null +++ b/QuickLocation/Component/CameraCapture/CameraCaptureView.swift @@ -0,0 +1,763 @@ +// +// CameraCaptureView.swift +// QuickLocation +// + +import UIKit +import AVFoundation + +enum CameraCaptureMode { + case scan + case photo + case video +} + +final class CameraCaptureView: UIView { + + var onClose: (() -> Void)? + var onScan: ((String) -> Void)? + var onPhoto: ((UIImage) -> Void)? + var onVideo: ((URL) -> Void)? + var onOpenAlbum: (() -> Void)? + + var maxVideoDuration: TimeInterval = 15 + + private(set) var mode: CameraCaptureMode + private(set) var isRecording = false + + private let session = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "camera.capture.session") + private var previewLayer: AVCaptureVideoPreviewLayer? + private var videoInput: AVCaptureDeviceInput? + private var audioInput: AVCaptureDeviceInput? + private var metaOutput: AVCaptureMetadataOutput? + private var photoOutput: AVCapturePhotoOutput? + private var movieOutput: AVCaptureMovieFileOutput? + + private var cameraPosition: AVCaptureDevice.Position = .back + private var isMuted = false + private var isTorchOn = false + private var didHandleScan = false + private var recordingTimer: Timer? + private var recordingStartedAt: Date? + private var progressDisplayLink: CADisplayLink? + private var capturedImage: UIImage? + private var isReviewingPhoto = false + + init(mode: CameraCaptureMode) { + self.mode = mode + super.init(frame: .zero) + backgroundColor = .black + setupUI() + applyModeUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + metaOutput?.setMetadataObjectsDelegate(nil, queue: nil) + progressDisplayLink?.invalidate() + scanLineView.layer.removeAllAnimations() + let session = self.session + sessionQueue.async { + if session.isRunning { + session.stopRunning() + } + } + } + + // MARK: - Public + + func startSession() { + didHandleScan = false + requestAccessAndConfigure() + if mode == .scan { startScanLine() } + } + + func stopSession() { + stopScanLine() + stopRecordingTimer() + metaOutput?.setMetadataObjectsDelegate(nil, queue: nil) + let session = self.session + sessionQueue.async { + if session.isRunning { + session.stopRunning() + } + } + } + + func setMode(_ mode: CameraCaptureMode) { + guard self.mode != mode, !isRecording else { return } + self.mode = mode + didHandleScan = false + applyModeUI() + sessionQueue.async { [weak self] in + self?.reconfigureOutputs() + } + if mode == .scan { + startScanLine() + } else { + stopScanLine() + } + } + + // MARK: - UI + + private lazy var backBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.backgroundColor = UIColor.white.withAlphaComponent(0.28) + btn.cornerRadius = 10 + let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold) + btn.setImage(UIImage(systemName: "chevron.left", withConfiguration: config), for: .normal) + btn.tintColor = .white + btn.extendEdgeInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) + btn.addTarget(self, action: #selector(tapBack), for: .touchUpInside) + return btn + }() + + private lazy var scanBoxView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.clipsToBounds = true + view.isUserInteractionEnabled = false + return view + }() + + private lazy var scanFrameView: UIImageView = { + let iv = UIImageView(image: UIImage(named: "Camera/scan_frame")) + iv.contentMode = .scaleAspectFit + return iv + }() + + private lazy var scanLineView: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.shadowColor = UIColor.white.cgColor + view.layer.shadowRadius = 4 + view.layer.shadowOpacity = 0.9 + view.layer.shadowOffset = .zero + return view + }() + + private lazy var leftBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.imageView?.contentMode = .scaleAspectFit + btn.addTarget(self, action: #selector(tapLeft), for: .touchUpInside) + return btn + }() + + private lazy var rightBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.imageView?.contentMode = .scaleAspectFit + btn.addTarget(self, action: #selector(tapRight), for: .touchUpInside) + return btn + }() + + private lazy var shutterBtn: CameraShutterButton = { + let btn = CameraShutterButton() + btn.addTarget(self, action: #selector(tapShutter), for: .touchUpInside) + return btn + }() + + private lazy var timerLab: UILabel = { + let label = UILabel() + label.font = .monospacedDigitSystemFont(ofSize: 16, weight: .medium) + label.textColor = .white + label.textAlignment = .center + label.isHidden = true + return label + }() + + private lazy var previewImageView: UIImageView = { + let iv = UIImageView() + iv.contentMode = .scaleAspectFill + iv.clipsToBounds = true + iv.isHidden = true + iv.isUserInteractionEnabled = false + iv.backgroundColor = .black + return iv + }() + + private lazy var retakeBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("重拍", for: .normal) + btn.setTitleColor(.white, for: .normal) + btn.titleLabel?.font = FontManager.boboBold(18) + btn.backgroundColor = UIColor.white.withAlphaComponent(0.22) + btn.cornerRadius = 25 + btn.isHidden = true + btn.addTarget(self, action: #selector(tapRetake), for: .touchUpInside) + return btn + }() + + private lazy var doneBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("完成", for: .normal) + btn.setTitleColor(.white, for: .normal) + btn.titleLabel?.font = FontManager.boboBold(18) + btn.backgroundColor = UIColor(hexStr: "#3ED0FF") + btn.cornerRadius = 25 + btn.isHidden = true + btn.addTarget(self, action: #selector(tapDone), for: .touchUpInside) + return btn + }() + + private func setupUI() { + addSubview(scanBoxView) + scanBoxView.addSubview(scanFrameView) + scanBoxView.addSubview(scanLineView) + addSubview(backBtn) + addSubview(leftBtn) + addSubview(rightBtn) + addSubview(shutterBtn) + addSubview(timerLab) + addSubview(previewImageView) + addSubview(retakeBtn) + addSubview(doneBtn) + + backBtn.layoutChain + .left(16) + .width(36) + .height(36) + backBtn.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true + + scanBoxView.layoutChain + .centerY() + .centerX() + .width(327) + .height(324) + + scanFrameView.layoutChain.edges() + + shutterBtn.layoutChain + .centerX() + .bottom(kSafeBottomMargin + 36) + .width(76) + .height(76) + + leftBtn.layoutChain + .centerY(shutterBtn) + .left(36) + .width(50) + .height(50) + + rightBtn.layoutChain + .centerY(shutterBtn) + .right(36) + .width(50) + .height(50) + + timerLab.layoutChain + .centerX() + .bottomToTopOfView(shutterBtn, offset: -14) + + previewImageView.layoutChain.edges() + + retakeBtn.layoutChain + .left(20) + .bottom(kSafeBottomMargin + 20) + .height(50) + .widthToView(doneBtn) + + doneBtn.layoutChain + .leftToRightOfView(retakeBtn, offset: 12) + .right(20) + .bottomToView(retakeBtn) + .height(50) + + raiseOverlayControls() + } + + private func raiseOverlayControls() { + bringSubviewToFront(backBtn) + bringSubviewToFront(retakeBtn) + bringSubviewToFront(doneBtn) + } + + private func applyModeUI() { + let reviewing = isReviewingPhoto + previewImageView.isHidden = !reviewing + retakeBtn.isHidden = !reviewing + doneBtn.isHidden = !reviewing + leftBtn.isHidden = reviewing + rightBtn.isHidden = reviewing + shutterBtn.isHidden = reviewing + if reviewing { + scanBoxView.isHidden = true + timerLab.isHidden = true + return + } + + let scanning = mode == .scan + scanBoxView.isHidden = !scanning + shutterBtn.isHidden = scanning + timerLab.isHidden = !(mode == .video && isRecording) + shutterBtn.isRecordingStyle = isRecording + + switch mode { + case .scan: + leftBtn.setImage(UIImage(named: "Camera/btn_flash"), for: .normal) + rightBtn.setImage(UIImage(named: "Camera/btn_album"), for: .normal) + case .photo: + leftBtn.setImage(UIImage(named: "Camera/btn_flash"), for: .normal) + rightBtn.setImage(UIImage(named: "Camera/btn_flip"), for: .normal) + case .video: + if isRecording { + leftBtn.setImage(UIImage(named: "Camera/btn_mute"), for: .normal) + leftBtn.alpha = isMuted ? 0.45 : 1 + } else { + leftBtn.setImage(UIImage(named: "Camera/btn_flash"), for: .normal) + leftBtn.alpha = 1 + } + rightBtn.setImage(UIImage(named: "Camera/btn_flip"), for: .normal) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + previewLayer?.frame = bounds + raiseOverlayControls() + if scanLineView.layer.animation(forKey: "scan") == nil, mode == .scan, !scanBoxView.isHidden { + startScanLine() + } + } + + // MARK: - Scan line + + func startScanLine() { + guard mode == .scan else { return } + layoutIfNeeded() + let box = scanBoxView.bounds + guard box.height > 20 else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.startScanLine() + } + return + } + scanLineView.layer.removeAnimation(forKey: "scan") + let inset: CGFloat = 18 + scanLineView.bounds = CGRect(x: 0, y: 0, width: max(box.width - inset * 2, 40), height: 2) + scanLineView.layer.cornerRadius = 1 + let minY = inset + let maxY = box.height - inset + scanLineView.center = CGPoint(x: box.midX, y: minY) + let anim = CABasicAnimation(keyPath: "position.y") + anim.fromValue = minY + anim.toValue = maxY + anim.duration = 1.6 + anim.autoreverses = false + anim.repeatCount = .infinity + anim.timingFunction = CAMediaTimingFunction(name: .linear) + scanLineView.layer.add(anim, forKey: "scan") + scanLineView.isHidden = false + } + + func stopScanLine() { + scanLineView.layer.removeAnimation(forKey: "scan") + scanLineView.isHidden = true + } + + // MARK: - Session + + private func requestAccessAndConfigure() { + let status = AVCaptureDevice.authorizationStatus(for: .video) + switch status { + case .authorized: + configureSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + if granted { self?.configureSession() } + else { DLToast.show(text: "请在设置中开启相机权限") } + } + } + default: + DLToast.show(text: "请在设置中开启相机权限") + } + } + + private func configureSession() { + sessionQueue.async { [weak self] in + guard let self else { return } + self.session.beginConfiguration() + self.session.sessionPreset = .high + self.attachVideoInput(position: self.cameraPosition) + if self.mode == .video { + self.attachAudioInput() + } + self.reconfigureOutputsLocked() + self.session.commitConfiguration() + self.applyMetadataTypes() + + let layer = AVCaptureVideoPreviewLayer(session: self.session) + layer.videoGravity = .resizeAspectFill + DispatchQueue.main.async { + self.previewLayer?.removeFromSuperlayer() + layer.frame = self.bounds + self.layer.insertSublayer(layer, at: 0) + self.previewLayer = layer + self.raiseOverlayControls() + if self.mode == .scan { self.startScanLine() } + } + if !self.session.isRunning { + self.session.startRunning() + } + self.applyMetadataTypes() + } + } + + private func reconfigureOutputs() { + session.beginConfiguration() + reconfigureOutputsLocked() + session.commitConfiguration() + applyMetadataTypes() + } + + private func reconfigureOutputsLocked() { + if let metaOutput { + session.removeOutput(metaOutput) + self.metaOutput = nil + } + if let photoOutput { + session.removeOutput(photoOutput) + self.photoOutput = nil + } + if let movieOutput { + session.removeOutput(movieOutput) + self.movieOutput = nil + } + + switch mode { + case .scan: + let output = AVCaptureMetadataOutput() + if session.canAddOutput(output) { + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + metaOutput = output + } + case .photo: + let output = AVCapturePhotoOutput() + if session.canAddOutput(output) { + session.addOutput(output) + photoOutput = output + } + case .video: + attachAudioInput() + let output = AVCaptureMovieFileOutput() + if session.canAddOutput(output) { + session.addOutput(output) + if let conn = output.connection(with: .video), conn.isVideoStabilizationSupported { + conn.preferredVideoStabilizationMode = .auto + } + movieOutput = output + } + } + } + + @discardableResult + private func attachVideoInput(position: AVCaptureDevice.Position) -> Bool { + if let videoInput { + session.removeInput(videoInput) + self.videoInput = nil + } + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return false } + session.addInput(input) + videoInput = input + cameraPosition = position + return true + } + + private func attachAudioInput() { + guard audioInput == nil else { return } + guard let device = AVCaptureDevice.default(for: .audio), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + session.addInput(input) + audioInput = input + } + + /// 会话 commit / startRunning 之后再设类型,否则 availableMetadataObjectTypes 经常是空的。 + private func applyMetadataTypes() { + guard mode == .scan, let output = metaOutput else { return } + let preferred: [AVMetadataObject.ObjectType] = [ + .qr, .code128, .ean13, .ean8, .upce, .code39, .code39Mod43 + ] + let available = output.availableMetadataObjectTypes + let types = preferred.filter { available.contains($0) } + if !types.isEmpty { + output.metadataObjectTypes = types + } else { + output.metadataObjectTypes = [.qr] + } + } + + // MARK: - Actions + + @objc private func tapBack() { + if isRecording { + stopRecording() + } + onClose?() + } + + @objc private func tapLeft() { + switch mode { + case .scan, .photo: + toggleTorch() + case .video: + if isRecording { toggleMute() } else { toggleTorch() } + } + } + + @objc private func tapRight() { + switch mode { + case .scan: + onOpenAlbum?() + case .photo, .video: + switchCamera() + } + } + + @objc private func tapRetake() { + capturedImage = nil + isReviewingPhoto = false + applyModeUI() + } + + @objc private func tapDone() { + guard let image = capturedImage else { return } + onPhoto?(image) + } + + @objc private func tapShutter() { + switch mode { + case .scan: + break + case .photo: + capturePhoto() + case .video: + if isRecording { stopRecording() } else { startRecording() } + } + } + + private func toggleTorch() { + guard cameraPosition == .back, let device = videoInput?.device, device.hasTorch else { + DLToast.show(text: "当前镜头不支持闪光灯") + return + } + do { + try device.lockForConfiguration() + isTorchOn.toggle() + device.torchMode = isTorchOn ? .on : .off + device.unlockForConfiguration() + } catch { + DLToast.show(text: "闪光灯开启失败") + } + } + + private func toggleMute() { + isMuted.toggle() + if let conn = movieOutput?.connection(with: .audio) { + conn.isEnabled = !isMuted + } + applyModeUI() + } + + private func switchCamera() { + let next: AVCaptureDevice.Position = cameraPosition == .back ? .front : .back + sessionQueue.async { [weak self] in + guard let self else { return } + self.session.beginConfiguration() + _ = self.attachVideoInput(position: next) + if self.isTorchOn, next == .front { + self.isTorchOn = false + } + self.session.commitConfiguration() + } + } + + private func capturePhoto() { + guard let photoOutput else { return } + let settings = AVCapturePhotoSettings() + if let device = videoInput?.device, device.hasFlash { + settings.flashMode = isTorchOn ? .on : .off + } + photoOutput.capturePhoto(with: settings, delegate: self) + } + + private func startRecording() { + let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) + if micStatus == .notDetermined { + AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in + DispatchQueue.main.async { + if granted { self?.startRecording() } + else { DLToast.show(text: "请在设置中开启麦克风权限") } + } + } + return + } + guard let movieOutput, !movieOutput.isRecording else { return } + let url = FileManager.default.temporaryDirectory.appendingPathComponent("cap_\(Int(Date().timeIntervalSince1970)).mov") + try? FileManager.default.removeItem(at: url) + if let conn = movieOutput.connection(with: .audio) { + conn.isEnabled = !isMuted + } + movieOutput.maxRecordedDuration = CMTime(seconds: maxVideoDuration, preferredTimescale: 600) + movieOutput.startRecording(to: url, recordingDelegate: self) + isRecording = true + recordingStartedAt = Date() + applyModeUI() + timerLab.isHidden = false + timerLab.text = "00:00" + startRecordingTimer() + } + + private func stopRecording() { + movieOutput?.stopRecording() + isRecording = false + stopRecordingTimer() + applyModeUI() + timerLab.isHidden = true + shutterBtn.progress = 0 + } + + private func startRecordingTimer() { + stopRecordingTimer() + let link = CADisplayLink(target: self, selector: #selector(tickRecording)) + link.add(to: .main, forMode: .common) + progressDisplayLink = link + } + + private func stopRecordingTimer() { + progressDisplayLink?.invalidate() + progressDisplayLink = nil + recordingTimer?.invalidate() + recordingTimer = nil + } + + @objc private func tickRecording() { + guard let start = recordingStartedAt else { return } + let elapsed = Date().timeIntervalSince(start) + let minutes = Int(elapsed) / 60 + let seconds = Int(elapsed) % 60 + timerLab.text = String(format: "%02d:%02d", minutes, seconds) + shutterBtn.progress = CGFloat(min(elapsed / maxVideoDuration, 1)) + if elapsed >= maxVideoDuration { + stopRecording() + } + } +} + +// MARK: - Scan +extension CameraCaptureView: AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput(_ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection) { + guard mode == .scan, !didHandleScan, + let obj = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let text = obj.stringValue, !text.isEmpty else { return } + didHandleScan = true + stopScanLine() + onScan?(text) + } +} + +// MARK: - Photo +extension CameraCaptureView: AVCapturePhotoCaptureDelegate { + func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { + if let error { + DLToast.show(text: "拍照失败:\(error.localizedDescription)") + return + } + guard let data = photo.fileDataRepresentation(), let image = UIImage(data: data) else { return } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.capturedImage = image + self.previewImageView.image = image + self.isReviewingPhoto = true + self.applyModeUI() + } + } +} + +// MARK: - Video +extension CameraCaptureView: AVCaptureFileOutputRecordingDelegate { + func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { + isRecording = false + DispatchQueue.main.async { [weak self] in + self?.stopRecordingTimer() + self?.applyModeUI() + self?.timerLab.isHidden = true + self?.shutterBtn.progress = 0 + } + if let error { + DLToast.show(text: "录像失败:\(error.localizedDescription)") + return + } + onVideo?(outputFileURL) + } +} + +// MARK: - Shutter +private final class CameraShutterButton: UIControl { + var isRecordingStyle = false { + didSet { setNeedsDisplay() } + } + var progress: CGFloat = 0 { + didSet { setNeedsDisplay() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isOpaque = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ rect: CGRect) { + let cyan = UIColor(hexStr: "#3ED0FF") + let ringW: CGFloat = 4 + let inset = ringW / 2 + 1 + let ringRect = rect.insetBy(dx: inset, dy: inset) + + if isRecordingStyle { + let inner = min(rect.width, rect.height) * 0.34 + let innerRect = CGRect(x: (rect.width - inner) / 2, y: (rect.height - inner) / 2, width: inner, height: inner) + cyan.setFill() + UIBezierPath(ovalIn: innerRect).fill() + + let path = UIBezierPath(ovalIn: ringRect) + path.lineWidth = ringW + UIColor.white.withAlphaComponent(0.25).setStroke() + path.stroke() + + let start = -CGFloat.pi / 2 + let end = start + 2 * .pi * min(max(progress, 0), 1) + let arc = UIBezierPath(arcCenter: CGPoint(x: rect.midX, y: rect.midY), + radius: ringRect.width / 2, + startAngle: start, + endAngle: end, + clockwise: true) + arc.lineWidth = ringW + arc.lineCapStyle = .round + cyan.setStroke() + arc.stroke() + } else { + let inner = min(rect.width, rect.height) - 16 + let innerRect = CGRect(x: (rect.width - inner) / 2, y: (rect.height - inner) / 2, width: inner, height: inner) + cyan.setFill() + UIBezierPath(ovalIn: innerRect).fill() + let path = UIBezierPath(ovalIn: ringRect) + path.lineWidth = ringW + cyan.setStroke() + path.stroke() + } + } +} diff --git a/QuickLocation/Component/PhotoPicker/PhotoPickerAlbumView.swift b/QuickLocation/Component/PhotoPicker/PhotoPickerAlbumView.swift new file mode 100644 index 00000000..d116dd75 --- /dev/null +++ b/QuickLocation/Component/PhotoPicker/PhotoPickerAlbumView.swift @@ -0,0 +1,112 @@ +// +// PhotoPickerAlbumView.swift +// QuickLocation +// + +import UIKit +import Photos + +final class PhotoPickerAlbumView: UIView, UITableViewDataSource, UITableViewDelegate { + + var onSelect: ((Int) -> Void)? + + private var albums: [PhotoAlbum] = [] + private var selectedIndex = 0 + private var store: PhotoPickerStore? + + private lazy var tableView: UITableView = { + let tv = UITableView(frame: .zero, style: .plain) + tv.backgroundColor = UIColor(hexStr: "#2C2C2E") + tv.separatorColor = UIColor.white.withAlphaComponent(0.12) + tv.separatorInset = UIEdgeInsets(top: 0, left: 76, bottom: 0, right: 0) + tv.rowHeight = 64 + tv.dataSource = self + tv.delegate = self + tv.tableFooterView = UIView() + tv.register(PhotoPickerAlbumCell.self, forCellReuseIdentifier: "cell") + return tv + }() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#2C2C2E") + addSubview(tableView) + tableView.layoutChain.edges() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func reload(albums: [PhotoAlbum], selectedIndex: Int) { + self.albums = albums + self.selectedIndex = selectedIndex + tableView.reloadData() + } + + func bindStore(_ store: PhotoPickerStore) { + self.store = store + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + albums.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! PhotoPickerAlbumCell + let album = albums[indexPath.row] + cell.configure(album: album, selected: indexPath.row == selectedIndex, store: store) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + selectedIndex = indexPath.row + tableView.reloadData() + onSelect?(indexPath.row) + } +} + +private final class PhotoPickerAlbumCell: UITableViewCell { + private let coverView = UIImageView() + private let titleLab = UILabel() + private let tickView = UIImageView() + private var assetId = "" + private var requestId: PHImageRequestID = PHInvalidImageRequestID + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = UIColor(hexStr: "#2C2C2E") + selectionStyle = .none + coverView.contentMode = .scaleAspectFill + coverView.clipsToBounds = true + coverView.cornerRadius = 4 + coverView.backgroundColor = UIColor.white.withAlphaComponent(0.08) + titleLab.font = .systemFont(ofSize: 16) + titleLab.textColor = .white + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .semibold) + tickView.image = UIImage(systemName: "checkmark", withConfiguration: config) + tickView.tintColor = UIColor(hexStr: "#007AFF") + tickView.contentMode = .scaleAspectFit + contentView.addSubview(coverView) + contentView.addSubview(titleLab) + contentView.addSubview(tickView) + coverView.layoutChain.left(16).centerY().width(48).height(48) + titleLab.layoutChain.leftToRightOfView(coverView, offset: 12).centerY().right(40) + tickView.layoutChain.right(16).centerY().width(18).height(18) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(album: PhotoAlbum, selected: Bool, store: PhotoPickerStore?) { + titleLab.text = "\(album.title) (\(album.count))" + tickView.isHidden = !selected + coverView.image = nil + assetId = album.coverAsset?.localIdentifier ?? "" + guard let store, let asset = album.coverAsset else { return } + if requestId != PHInvalidImageRequestID { + store.cancelRequest(requestId) + } + requestId = store.requestThumbnail(for: asset, size: CGSize(width: 48, height: 48)) { [weak self] image in + guard let self, self.assetId == asset.localIdentifier else { return } + self.coverView.image = image + } + } +} diff --git a/QuickLocation/Component/PhotoPicker/PhotoPickerPreviewVC.swift b/QuickLocation/Component/PhotoPicker/PhotoPickerPreviewVC.swift new file mode 100644 index 00000000..3b157a4a --- /dev/null +++ b/QuickLocation/Component/PhotoPicker/PhotoPickerPreviewVC.swift @@ -0,0 +1,163 @@ +// +// PhotoPickerPreviewVC.swift +// QuickLocation +// + +import UIKit +import Photos + +final class PhotoPickerPreviewVC: UIViewController, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + + override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } + + private let assets: [PHAsset] + private let store: PhotoPickerStore + private var currentIndex: Int + private var lastPageSize: CGSize = .zero + + init(assets: [PHAsset], startIndex: Int, store: PhotoPickerStore) { + self.assets = assets + self.store = store + self.currentIndex = min(max(startIndex, 0), max(assets.count - 1, 0)) + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private lazy var closeBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "PhotoPicker/btn_close"), for: .normal) + btn.extendEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) + btn.addTarget(self, action: #selector(tapClose), for: .touchUpInside) + return btn + }() + + private lazy var titleLab: UILabel = { + let label = UILabel() + label.text = "预览" + label.textColor = .white + label.font = .systemFont(ofSize: 17, weight: .medium) + return label + }() + + private lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = 0 + layout.minimumInteritemSpacing = 0 + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.isPagingEnabled = true + cv.showsHorizontalScrollIndicator = false + cv.backgroundColor = .black + cv.dataSource = self + cv.delegate = self + cv.register(PhotoPickerPreviewCell.self, forCellWithReuseIdentifier: "cell") + return cv + }() + + private lazy var pageLab: UILabel = { + let label = UILabel() + label.textColor = .white + label.font = .systemFont(ofSize: 13, weight: .medium) + label.textAlignment = .center + label.backgroundColor = UIColor.black.withAlphaComponent(0.45) + label.cornerRadius = 12 + label.clipsToBounds = true + return label + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + view.addSubview(collectionView) + view.addSubview(closeBtn) + view.addSubview(titleLab) + view.addSubview(pageLab) + + collectionView.layoutChain.edges() + closeBtn.layoutChain.left(16).width(32).height(32) + closeBtn.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 6).isActive = true + titleLab.layoutChain.centerX().centerY(closeBtn) + pageLab.layoutChain.centerX().width(52).height(24) + pageLab.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16).isActive = true + updatePageLabel() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let size = collectionView.bounds.size + guard size.width > 1 else { return } + if size != lastPageSize { + lastPageSize = size + collectionView.collectionViewLayout.invalidateLayout() + } + guard assets.indices.contains(currentIndex) else { return } + let offset = CGFloat(currentIndex) * size.width + if abs(collectionView.contentOffset.x - offset) > 1 { + collectionView.setContentOffset(CGPoint(x: offset, y: 0), animated: false) + } + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + assets.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! PhotoPickerPreviewCell + cell.configure(asset: assets[indexPath.item], store: store) + return cell + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + collectionView.bounds.size + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let width = scrollView.bounds.width + guard width > 0 else { return } + let index = Int(round(scrollView.contentOffset.x / width)) + let clamped = min(max(index, 0), max(assets.count - 1, 0)) + if clamped != currentIndex { + currentIndex = clamped + updatePageLabel() + } + } + + private func updatePageLabel() { + pageLab.text = "\(currentIndex + 1)/\(max(assets.count, 1))" + } + + @objc private func tapClose() { + dismiss(animated: true) + } +} + +private final class PhotoPickerPreviewCell: UICollectionViewCell { + private let imageView = UIImageView() + private var assetId = "" + private var requestId: PHImageRequestID = PHInvalidImageRequestID + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .black + imageView.contentMode = .scaleAspectFit + contentView.addSubview(imageView) + imageView.layoutChain.edges() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(asset: PHAsset, store: PhotoPickerStore) { + if requestId != PHInvalidImageRequestID { + store.cancelRequest(requestId) + } + assetId = asset.localIdentifier + imageView.image = nil + let size = UIScreen.main.bounds.size + requestId = store.requestThumbnail(for: asset, size: size) { [weak self] image in + guard let self, self.assetId == asset.localIdentifier else { return } + self.imageView.image = image + } + } +} diff --git a/QuickLocation/Component/PhotoPicker/PhotoPickerStore.swift b/QuickLocation/Component/PhotoPicker/PhotoPickerStore.swift new file mode 100644 index 00000000..187907ea --- /dev/null +++ b/QuickLocation/Component/PhotoPicker/PhotoPickerStore.swift @@ -0,0 +1,169 @@ +// +// PhotoPickerStore.swift +// QuickLocation +// + +import UIKit +import Photos + +struct PhotoAlbum { + let collection: PHAssetCollection + let title: String + let count: Int + let coverAsset: PHAsset? +} + +final class PhotoPickerStore { + + private let imageManager = PHCachingImageManager() + private let imageOptions: PHImageRequestOptions = { + let options = PHImageRequestOptions() + options.deliveryMode = .opportunistic + options.resizeMode = .fast + options.isNetworkAccessAllowed = true + return options + }() + + private(set) var albums: [PhotoAlbum] = [] + private(set) var assets: [PHAsset] = [] + + func requestAccess(_ completion: @escaping (Bool) -> Void) { + let finish: (PHAuthorizationStatus) -> Void = { status in + DispatchQueue.main.async { + if #available(iOS 14, *) { + completion(status == .authorized || status == .limited) + } else { + completion(status == .authorized) + } + } + } + if #available(iOS 14, *) { + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + if status == .notDetermined { + PHPhotoLibrary.requestAuthorization(for: .readWrite, handler: finish) + } else { + finish(status) + } + } else { + let status = PHPhotoLibrary.authorizationStatus() + if status == .notDetermined { + PHPhotoLibrary.requestAuthorization(finish) + } else { + finish(status) + } + } + } + + func reloadAlbums() { + var list: [PhotoAlbum] = [] + let smart = PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .any, options: nil) + smart.enumerateObjects { collection, _, _ in + guard let album = self.makeAlbum(collection) else { return } + if collection.assetCollectionSubtype == .smartAlbumUserLibrary { + list.insert(album, at: 0) + } else { + list.append(album) + } + } + let user = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .any, options: nil) + user.enumerateObjects { collection, _, _ in + guard let album = self.makeAlbum(collection) else { return } + list.append(album) + } + albums = list + if let first = albums.first { + loadAssets(in: first) + } else { + assets = [] + } + } + + func loadAssets(in album: PhotoAlbum) { + assets = fetchImages(in: album.collection) + } + + func requestThumbnail(for asset: PHAsset, size: CGSize, completion: @escaping (UIImage?) -> Void) -> PHImageRequestID { + let scale = UIScreen.main.scale + let target = CGSize(width: size.width * scale, height: size.height * scale) + return imageManager.requestImage(for: asset, + targetSize: target, + contentMode: .aspectFill, + options: imageOptions) { image, info in + let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false + if image != nil || !degraded { + completion(image) + } + } + } + + func cancelRequest(_ id: PHImageRequestID) { + imageManager.cancelImageRequest(id) + } + + func requestFullImages(for assets: [PHAsset], completion: @escaping ([UIImage]) -> Void) { + guard !assets.isEmpty else { + completion([]) + return + } + let options = PHImageRequestOptions() + options.deliveryMode = .highQualityFormat + options.resizeMode = .none + options.isNetworkAccessAllowed = true + options.isSynchronous = false + + let group = DispatchGroup() + var images: [UIImage?] = Array(repeating: nil, count: assets.count) + var finished = Array(repeating: false, count: assets.count) + let lock = NSLock() + let maxSide = max(UIScreen.main.bounds.width, UIScreen.main.bounds.height) * UIScreen.main.scale * 2 + let target = CGSize(width: maxSide, height: maxSide) + + for (index, asset) in assets.enumerated() { + group.enter() + imageManager.requestImage(for: asset, + targetSize: target, + contentMode: .aspectFit, + options: options) { image, info in + let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false + let cancelled = (info?[PHImageCancelledKey] as? Bool) ?? false + let error = info?[PHImageErrorKey] as? Error + lock.lock() + let already = finished[index] + if !already, !degraded || image == nil || cancelled || error != nil { + finished[index] = true + if !degraded { images[index] = image } + lock.unlock() + group.leave() + } else { + lock.unlock() + } + } + } + group.notify(queue: .main) { + completion(images.compactMap { $0 }) + } + } + + private func makeAlbum(_ collection: PHAssetCollection) -> PhotoAlbum? { + if collection.assetCollectionSubtype == .smartAlbumAllHidden { return nil } + let hiddenTitles: Set = ["最近删除", "Recently Deleted", "Hidden", "已隐藏"] + if let title = collection.localizedTitle, hiddenTitles.contains(title) { return nil } + let images = fetchImages(in: collection) + guard !images.isEmpty else { return nil } + let title = collection.localizedTitle?.isEmpty == false ? collection.localizedTitle! : "相册" + return PhotoAlbum(collection: collection, title: title, count: images.count, coverAsset: images.first) + } + + private func fetchImages(in collection: PHAssetCollection) -> [PHAsset] { + let options = PHFetchOptions() + options.predicate = NSPredicate(format: "mediaType == %d", PHAssetMediaType.image.rawValue) + options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + let result = PHAsset.fetchAssets(in: collection, options: options) + var list: [PHAsset] = [] + list.reserveCapacity(result.count) + result.enumerateObjects { asset, _, _ in + list.append(asset) + } + return list + } +} diff --git a/QuickLocation/Component/PhotoPicker/PhotoPickerVC.swift b/QuickLocation/Component/PhotoPicker/PhotoPickerVC.swift new file mode 100644 index 00000000..1d5e61f4 --- /dev/null +++ b/QuickLocation/Component/PhotoPicker/PhotoPickerVC.swift @@ -0,0 +1,121 @@ +// +// PhotoPickerVC.swift +// QuickLocation +// + +import UIKit +import Photos + +final class PhotoPickerVC: BaseViewController { + + override var isNavigationBarHidden: Bool { true } + override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } + + var onConfirm: (([UIImage]) -> Void)? + + private let maxCount: Int + private let store = PhotoPickerStore() + private var rootView: PhotoPickerView! + private var selected: [PHAsset] = [] + private var currentAlbumIndex = 0 + private var isExporting = false + + init(maxCount: Int = 9) { + self.maxCount = max(1, maxCount) + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + rootView = PhotoPickerView(store: store, maxCount: maxCount) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + rootView.bindAlbumStore(store) + bind() + store.requestAccess { [weak self] granted in + guard let self else { return } + guard granted else { + DLToast.show(text: "请在设置中开启相册权限") + self.dismiss(animated: true) + return + } + self.store.reloadAlbums() + self.currentAlbumIndex = 0 + self.rootView.reloadAlbums(currentIndex: 0) + self.rootView.reloadGrid() + } + } + + private func bind() { + rootView.onClose = { [weak self] in + self?.dismiss(animated: true) + } + rootView.onToggleAlbum = { [weak self] in + guard let self else { return } + self.rootView.isAlbumOpen.toggle() + } + rootView.onPickAlbum = { [weak self] index in + guard let self, self.store.albums.indices.contains(index) else { return } + self.currentAlbumIndex = index + self.store.loadAssets(in: self.store.albums[index]) + self.rootView.reloadAlbums(currentIndex: index) + self.rootView.reloadGrid() + self.rootView.isAlbumOpen = false + } + rootView.onSelectAsset = { [weak self] index in + self?.toggleAsset(at: index) + } + rootView.onPreview = { [weak self] in + self?.showPreview() + } + rootView.onConfirm = { [weak self] in + self?.confirmSelection() + } + } + + private func toggleAsset(at index: Int) { + guard store.assets.indices.contains(index) else { return } + let asset = store.assets[index] + if let idx = selected.firstIndex(where: { $0.localIdentifier == asset.localIdentifier }) { + selected.remove(at: idx) + } else { + if selected.count >= maxCount { + rootView.showLimitToast() + return + } + selected.append(asset) + } + rootView.applySelection(selected.map(\.localIdentifier)) + } + + private func showPreview() { + guard !selected.isEmpty else { return } + let vc = PhotoPickerPreviewVC(assets: selected, startIndex: 0, store: store) + present(vc, animated: true) + } + + private func confirmSelection() { + guard !isExporting else { return } + guard !selected.isEmpty else { + dismiss(animated: true) + return + } + isExporting = true + DLToast.showLoading() + store.requestFullImages(for: selected) { [weak self] images in + DLToast.dismiss() + guard let self else { return } + self.isExporting = false + self.dismiss(animated: true) { + self.onConfirm?(images) + } + } + } +} diff --git a/QuickLocation/Component/PhotoPicker/PhotoPickerView.swift b/QuickLocation/Component/PhotoPicker/PhotoPickerView.swift new file mode 100644 index 00000000..265ba30d --- /dev/null +++ b/QuickLocation/Component/PhotoPicker/PhotoPickerView.swift @@ -0,0 +1,389 @@ +// +// PhotoPickerView.swift +// QuickLocation +// + +import UIKit +import Photos + +final class PhotoPickerView: UIView { + + var onClose: (() -> Void)? + var onToggleAlbum: (() -> Void)? + var onPreview: (() -> Void)? + var onConfirm: (() -> Void)? + var onSelectAsset: ((Int) -> Void)? + var onPickAlbum: ((Int) -> Void)? + + var isAlbumOpen = false { + didSet { applyAlbumOpen() } + } + + private let store: PhotoPickerStore + private let maxCount: Int + private var selectedIds: [String] = [] + private var currentAlbumIndex = 0 + private var lastGridWidth: CGFloat = 0 + + init(store: PhotoPickerStore, maxCount: Int) { + self.store = store + self.maxCount = maxCount + super.init(frame: .zero) + backgroundColor = UIColor(hexStr: "#2C2C2E") + setupUI() + applyAlbumOpen() + updateBottomBar(selectedCount: 0) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + let width = collectionView.bounds.width + if width > 1, abs(width - lastGridWidth) > 0.5 { + lastGridWidth = width + collectionView.collectionViewLayout.invalidateLayout() + } + } + + func reloadGrid() { + collectionView.reloadData() + } + + func bindAlbumStore(_ store: PhotoPickerStore) { + albumListView.bindStore(store) + } + + func reloadAlbums(currentIndex: Int) { + currentAlbumIndex = currentIndex + albumListView.reload(albums: store.albums, selectedIndex: currentIndex) + let title = store.albums[safe: currentIndex]?.title ?? "最近项目" + albumTitleLab.text = title + } + + func applySelection(_ ids: [String]) { + selectedIds = ids + updateBottomBar(selectedCount: ids.count) + collectionView.reloadData() + } + + func showLimitToast() { + limitBanner.isHidden = false + NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(hideLimitToast), object: nil) + perform(#selector(hideLimitToast), with: nil, afterDelay: 1.8) + } + + func thumbnailSize() -> CGSize { + let side = collectionView.bounds.width > 0 + ? (collectionView.bounds.width - 3) / 4 + : (UIScreen.main.bounds.width - 3) / 4 + return CGSize(width: side, height: side) + } + + // MARK: - UI + + private lazy var closeBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "PhotoPicker/btn_close"), for: .normal) + btn.extendEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) + btn.addTarget(self, action: #selector(tapClose), for: .touchUpInside) + return btn + }() + + private lazy var albumTitleBtn: UIControl = { + let control = UIControl() + control.backgroundColor = UIColor.white.withAlphaComponent(0.12) + control.cornerRadius = 16 + control.addTarget(self, action: #selector(tapAlbum), for: .touchUpInside) + return control + }() + + private lazy var albumTitleLab: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .medium) + label.textColor = .white + label.text = "最近项目" + return label + }() + + private lazy var albumArrowView: UIImageView = { + let iv = UIImageView(image: UIImage(named: "PhotoPicker/btn_album_arrow")) + iv.contentMode = .scaleAspectFit + return iv + }() + + lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.minimumInteritemSpacing = 1 + layout.minimumLineSpacing = 1 + layout.sectionInset = .zero + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.backgroundColor = UIColor(hexStr: "#2C2C2E") + cv.register(PhotoPickerCell.self, forCellWithReuseIdentifier: "cell") + cv.dataSource = self + cv.delegate = self + cv.alwaysBounceVertical = true + return cv + }() + + private lazy var dimView: UIControl = { + let view = UIControl() + view.backgroundColor = UIColor.black.withAlphaComponent(0.45) + view.addTarget(self, action: #selector(tapAlbum), for: .touchUpInside) + return view + }() + + private lazy var albumListView: PhotoPickerAlbumView = { + let view = PhotoPickerAlbumView() + view.onSelect = { [weak self] index in + self?.onPickAlbum?(index) + } + return view + }() + + private lazy var bottomBar: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#1C1C1E") + return view + }() + + private lazy var previewBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("预览", for: .normal) + btn.setTitleColor(UIColor.white.withAlphaComponent(0.35), for: .disabled) + btn.setTitleColor(.white, for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 16) + btn.contentHorizontalAlignment = .left + btn.addTarget(self, action: #selector(tapPreview), for: .touchUpInside) + return btn + }() + + private lazy var confirmBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("确认", for: .normal) + btn.setTitleColor(.white, for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + btn.backgroundColor = UIColor(hexStr: "#007AFF") + btn.cornerRadius = 8 + btn.addTarget(self, action: #selector(tapConfirm), for: .touchUpInside) + return btn + }() + + private lazy var limitBanner: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#3A3A3C") + view.isHidden = true + let icon = UILabel() + icon.text = "!" + icon.textColor = .white + icon.font = .systemFont(ofSize: 12, weight: .bold) + icon.textAlignment = .center + icon.backgroundColor = .clear + icon.layer.borderColor = UIColor.white.cgColor + icon.layer.borderWidth = 1.2 + icon.layer.cornerRadius = 8 + icon.clipsToBounds = true + let label = UILabel() + label.text = "最多选取\(maxCount)张图片哦~" + label.textColor = .white + label.font = .systemFont(ofSize: 14) + view.addSubview(icon) + view.addSubview(label) + icon.layoutChain.left(24).centerY().width(16).height(16) + label.layoutChain.leftToRightOfView(icon, offset: 8).centerY() + return view + }() + + private var albumListHeight: NSLayoutConstraint? + + private func setupUI() { + addSubview(closeBtn) + addSubview(albumTitleBtn) + albumTitleBtn.addSubview(albumTitleLab) + albumTitleBtn.addSubview(albumArrowView) + addSubview(collectionView) + addSubview(dimView) + addSubview(albumListView) + addSubview(limitBanner) + addSubview(bottomBar) + bottomBar.addSubview(previewBtn) + bottomBar.addSubview(confirmBtn) + + closeBtn.layoutChain + .left(16) + .width(32) + .height(32) + closeBtn.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 6).isActive = true + + albumTitleBtn.layoutChain + .centerX() + .centerY(closeBtn) + .height(32) + + albumTitleLab.layoutChain + .left(12) + .centerY() + albumArrowView.layoutChain + .leftToRightOfView(albumTitleLab, offset: 6) + .right(8) + .centerY() + .width(20) + .height(20) + + collectionView.layoutChain + .topToBottomOfView(closeBtn, offset: 10) + .left() + .right() + + bottomBar.layoutChain + .left() + .right() + .bottom() + .topToBottomOfView(collectionView) + + let bottomH = 56 + kSafeBottomMargin + bottomBar.layoutChain.height(bottomH) + + previewBtn.layoutChain + .left(20) + .top(16) + .height(24) + confirmBtn.layoutChain + .right(16) + .top(10) + .width(88) + .height(36) + + limitBanner.layoutChain + .left() + .right() + .bottomToTopOfView(bottomBar) + .height(44) + + dimView.layoutChain + .topToBottomOfView(closeBtn, offset: 10) + .left() + .right() + .bottomToTopOfView(bottomBar) + + albumListView.layoutChain + .topToView(dimView) + .left() + .right() + albumListHeight = albumListView.heightAnchor.constraint(equalToConstant: 0) + albumListHeight?.isActive = true + } + + private func applyAlbumOpen() { + dimView.isHidden = !isAlbumOpen + albumListView.isHidden = !isAlbumOpen + let count = store.albums.count + let rowH: CGFloat = 64 + let maxH = min(CGFloat(count) * rowH, UIScreen.main.bounds.height * 0.42) + albumListHeight?.constant = isAlbumOpen ? maxH : 0 + UIView.animate(withDuration: 0.22) { + self.albumArrowView.transform = self.isAlbumOpen + ? CGAffineTransform(rotationAngle: .pi) + : .identity + self.layoutIfNeeded() + } + } + + private func updateBottomBar(selectedCount: Int) { + previewBtn.isEnabled = selectedCount > 0 + if selectedCount > 0 { + previewBtn.setTitle("预览 (\(selectedCount))", for: .normal) + } else { + previewBtn.setTitle("预览", for: .normal) + } + confirmBtn.alpha = selectedCount > 0 ? 1 : 0.55 + } + + @objc private func tapClose() { onClose?() } + @objc private func tapAlbum() { onToggleAlbum?() } + @objc private func tapPreview() { onPreview?() } + @objc private func tapConfirm() { onConfirm?() } + @objc private func hideLimitToast() { limitBanner.isHidden = true } +} + +extension PhotoPickerView: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + store.assets.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! PhotoPickerCell + let asset = store.assets[indexPath.item] + let index = selectedIds.firstIndex(of: asset.localIdentifier) + cell.configure(asset: asset, selectedIndex: index, store: store, size: thumbnailSize()) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + onSelectAsset?(indexPath.item) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + thumbnailSize() + } +} + +private final class PhotoPickerCell: UICollectionViewCell { + private let imageView = UIImageView() + private let dimView = UIView() + private let badge = UILabel() + private var requestId: PHImageRequestID = PHInvalidImageRequestID + private var assetId: String = "" + + override init(frame: CGRect) { + super.init(frame: frame) + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + dimView.backgroundColor = UIColor.black.withAlphaComponent(0.28) + dimView.isHidden = true + badge.textAlignment = .center + badge.font = .systemFont(ofSize: 13, weight: .semibold) + badge.layer.cornerRadius = 11 + badge.clipsToBounds = true + contentView.addSubview(imageView) + contentView.addSubview(dimView) + contentView.addSubview(badge) + imageView.layoutChain.edges() + dimView.layoutChain.edges() + badge.layoutChain.top(5).right(5).width(22).height(22) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(asset: PHAsset, selectedIndex: Int?, store: PhotoPickerStore, size: CGSize) { + if requestId != PHInvalidImageRequestID { + store.cancelRequest(requestId) + } + assetId = asset.localIdentifier + imageView.image = nil + let selected = selectedIndex != nil + dimView.isHidden = !selected + if let selectedIndex { + badge.text = "\(selectedIndex + 1)" + badge.textColor = .white + badge.backgroundColor = UIColor(hexStr: "#007AFF") + badge.layer.borderWidth = 0 + } else { + badge.text = "" + badge.backgroundColor = UIColor.black.withAlphaComponent(0.18) + badge.layer.borderWidth = 1.5 + badge.layer.borderColor = UIColor.white.cgColor + } + requestId = store.requestThumbnail(for: asset, size: size) { [weak self] image in + guard let self, self.assetId == asset.localIdentifier else { return } + self.imageView.image = image + } + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + assetId = "" + } +} + diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift index cd5d6ae4..bec549b8 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift @@ -13,7 +13,7 @@ import OpenIMSDK import AVFoundation import AudioToolbox import Speech -import HXPHPicker + import IQKeyboardManagerSwift import MJRefresh import CoreLocation @@ -33,7 +33,7 @@ final class GroupChatVC: BaseViewController { /// mention 列表:首行 @所有人 + 成员 private var mentionRows: [(userId: String?, nickname: String)] = [] private var isMentionPickerVisible = false - private var cameraPicker: ImagePicker? + /// 加载更早历史前记录,用于保持阅读位置 private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)? private var didSetupKeyboard = false @@ -594,21 +594,13 @@ final class GroupChatVC: BaseViewController { // MARK: - 显示相册 private func showAlbum() { - // 设置与微信主题一致的配置 - let config = PhotoTools.getWXPickerConfig() - // 最多可以选择的资源数,如果为0则不限制 - config.selectOptions = [.photo] - config.selectMode = .multiple - config.maximumSelectedCount = 9 -// config.maximumSelectedPhotoFileSize = 5242880 - config.allowSyncICloudWhenSelectPhoto = false - config.previewView.bottomView.editButtonHidden = true - config.photoList.allowAddCamera = true - config.photoList.camera.allowsEditing = false - let pickerController = PhotoPickerController(picker: config) - pickerController.pickerDelegate = self - pickerController.modalPresentationStyle = .fullScreen - self.present(pickerController, animated: true, completion: nil) + let picker = PhotoPickerVC(maxCount: 9) + picker.onConfirm = { [weak self] images in + for image in images { + self?.sendImageMessage(image) + } + } + present(picker, animated: true) } private func showCamera() { @@ -616,14 +608,14 @@ final class GroupChatVC: BaseViewController { DLToast.showError(text: "当前设备不支持拍照") return } - let picker = ImagePicker() - picker.imageDidPicked = { [weak self] image in - DispatchQueue.main.async { + let vc = CameraCaptureVC(mode: .photo) + vc.onPhoto = { [weak self] image in + self?.dismiss(animated: true) { self?.sendImageMessage(image) } } - cameraPicker = picker - picker.showCamera() + vc.modalPresentationStyle = .fullScreen + present(vc, animated: true) } private func showLocationPicker() { @@ -898,6 +890,12 @@ final class GroupChatVC: BaseViewController { cell.onImageTap = { [weak self] in self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView) } + cell.onNeedRelayout = { [weak tableView] in + UIView.performWithoutAnimation { + tableView?.beginUpdates() + tableView?.endUpdates() + } + } return cell case let .imageReceived(msg): let cell: ImageReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) @@ -905,6 +903,12 @@ final class GroupChatVC: BaseViewController { cell.onImageTap = { [weak self] in self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView) } + cell.onNeedRelayout = { [weak tableView] in + UIView.performWithoutAnimation { + tableView?.beginUpdates() + tableView?.endUpdates() + } + } return cell case let .locationSend(msg): let cell: LocationSendMsgCell = tableView.dequeueReusableCell(for: indexPath) @@ -999,33 +1003,16 @@ private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener { } } -// MARK: - PhotoPickerControllerDelegate -extension GroupChatVC: PhotoPickerControllerDelegate { - /// 选择完成之后调用 - /// - Parameters: - /// - pickerController: 对应的 PhotoPickerController - /// - result: 选择的结果 - /// result.photoAssets 选择的资源数组 - /// result.isOriginal 是否选中原图 - func pickerController(_ pickerController: PhotoPickerController, - didFinishSelection result: PickerResult) { - result.getImage { (image, photoAsset, index) in - } completionHandler: { [weak self] (images) in - guard let self = self else { return } - for img in images { - self.sendImageMessage(img) - } - } - } - +extension GroupChatVC { private func sendImageMessage(_ image: UIImage) { - guard let data = image.jpegData(compressionQuality: 0.8) else { return } + let sendImage = image.cgImage != nil ? image.fixOrientation() : image + guard let data = sendImage.jpegData(compressionQuality: 0.8) else { return } let dir = NSTemporaryDirectory() let filename = "img_\(Int(Date().timeIntervalSince1970)).jpg" let fileURL = URL(fileURLWithPath: dir + filename) try? data.write(to: fileURL) - let displaySize = Self.imageDisplaySize(w: image.size.width, h: image.size.height) + let displaySize = ChatImageLayout.displaySize(width: sendImage.size.width, height: sendImage.size.height) let msg = OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path) // 使用 SDK 的 clientMsgID 作为本地消息 ID,方便后续与监听器去重 @@ -1078,21 +1065,6 @@ extension GroupChatVC: PhotoPickerControllerDelegate { } }) } - - private static func imageDisplaySize(w: CGFloat, h: CGFloat) -> CGSize { - guard w > 0, h > 0 else { return CGSize(width: 160, height: 160) } - let maxW: CGFloat = 200, maxH: CGFloat = 250, minW: CGFloat = 80 - var dw = maxW, dh = dw * (h / w) - if dh > maxH { dh = maxH; dw = dh * (w / h) } - if dw < minW { dw = minW; dh = dw * (h / w) } - return CGSize(width: dw, height: dh) - } - - /// 点击取消时调用 - /// - Parameter pickerController: 对应的 PhotoPickerController - func pickerController(didCancel pickerController: PhotoPickerController) { - - } } // MARK: - 群聊位置选择 diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift index c24fceb2..35cd92b1 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift @@ -19,6 +19,29 @@ enum VoiceRecordState { case ended(URL?) // nil = cancelled } +/// 聊天图片气泡尺寸:按原图宽高缩放,忽略 snapshot 的正方形裁剪。 +enum ChatImageLayout { + static let maxWidth: CGFloat = 200 + static let maxHeight: CGFloat = 250 + static let minWidth: CGFloat = 80 + static let fallback = CGSize(width: 160, height: 120) + + static func displaySize(width: CGFloat, height: CGFloat) -> CGSize { + guard width > 1, height > 1, width.isFinite, height.isFinite else { return fallback } + var dw = maxWidth + var dh = dw * (height / width) + if dh > maxHeight { + dh = maxHeight + dw = dh * (width / height) + } + if dw < minWidth { + dw = minWidth + dh = dw * (height / width) + } + return CGSize(width: dw.rounded(), height: dh.rounded()) + } +} + // MARK: - Message Model struct ChatMessage { let id: String @@ -578,6 +601,7 @@ class GroupChatView: UIView { tv.register(LocationReceivedMsgCell.self) tv.register(NotificationMsgCell.self) tv.rowHeight = UITableView.automaticDimension + tv.estimatedRowHeight = 160 tv.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0) return tv }() @@ -1983,26 +2007,71 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { } -// MARK: - 发送的图片消息 -final class ImageSendMsgCell: UITableViewCell { +// MARK: - 图片消息(收发共用尺寸,避免接收 cell 再走一套正方形逻辑) +final class ImageSendMsgCell: ChatImageMsgCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier, isOutgoing: true) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +final class ImageReceivedMsgCell: ChatImageMsgCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier, isOutgoing: false) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +class ChatImageMsgCell: UITableViewCell { var menuAnchorView: UIView { photoView } var onImageTap: (() -> Void)? + var onNeedRelayout: (() -> Void)? + + private let isOutgoing: Bool + private var configuredId: String? func configure(_ msg: ChatMessage) { + configuredId = msg.id timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) - if !msg.imageUrl.isEmpty { - if let localImage = UIImage(contentsOfFile: msg.imageUrl) { - photoView.image = localImage - } else { - photoView.dl.setImage(with: msg.imageUrl) - } - } - photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight) + applyPhotoSize(ChatImageLayout.displaySize(width: msg.imageWidth, height: msg.imageHeight)) + loadPhoto(url: msg.imageUrl, messageId: msg.id, preferred: CGSize(width: msg.imageWidth, height: msg.imageHeight)) + guard isOutgoing else { return } loadingView.isHidden = !msg.isUploading - if msg.isUploading { loadingView.startAnimating() } + if msg.isUploading { loadingView.startAnimating() } else { loadingView.stopAnimating() } + } + + private func loadPhoto(url: String, messageId: String, preferred: CGSize) { + guard !url.isEmpty else { photoView.image = nil; return } + if let localImage = UIImage(contentsOfFile: url) { + photoView.image = localImage + syncPhotoSizeIfNeeded(localImage.size, preferred: preferred) + return + } + photoView.dl.setImage(with: url) { [weak self] image, _ in + guard let self, self.configuredId == messageId, let image else { return } + self.syncPhotoSizeIfNeeded(image.size, preferred: preferred) + } + } + + private func syncPhotoSizeIfNeeded(_ imageSize: CGSize, preferred: CGSize) { + let size = ChatImageLayout.displaySize(width: imageSize.width, height: imageSize.height) + let current = preferred.width > 1 && preferred.height > 1 + ? ChatImageLayout.displaySize(width: preferred.width, height: preferred.height) + : ChatImageLayout.fallback + guard abs(size.width - current.width) > 2 || abs(size.height - current.height) > 2 else { return } + applyPhotoSize(size) + let id = configuredId + DispatchQueue.main.async { [weak self] in + guard let self, self.configuredId == id else { return } + self.onNeedRelayout?() + } + } + + private func applyPhotoSize(_ size: CGSize) { + photoView.layoutChain.width(size.width).height(size.height) } @objc private func onTap() { onImageTap?() } @@ -2028,8 +2097,6 @@ final class ImageSendMsgCell: UITableViewCell { iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") -// iv.borderWidth = 2 -// iv.borderColor = .white return iv }() @@ -2045,8 +2112,23 @@ final class ImageSendMsgCell: UITableViewCell { return iv }() - override init(style: CellStyle, reuseIdentifier: String?) { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + self.isOutgoing = false super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + init(style: UITableViewCell.CellStyle, reuseIdentifier: String?, isOutgoing: Bool) { + self.isOutgoing = isOutgoing + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) @@ -2056,112 +2138,50 @@ final class ImageSendMsgCell: UITableViewCell { let tap = UITapGestureRecognizer(target: self, action: #selector(onTap)) photoView.addGestureRecognizer(tap) - photoView.addSubview(loadingView) - loadingView.layoutChain.centerX().centerY() timeLabel.layoutChain.top().centerX() - - avatarView.layoutChain - .topToBottomOfView(timeLabel, offset: 10) - .right(12).width(38).height(38) - senderNameView.translatesAutoresizingMaskIntoConstraints = false - senderNameView.layoutChain - .topToView(avatarView) - .rightToLeftOfView(avatarView, offset: -9) - photoView.layoutChain - .topToBottomOfView(senderNameView, offset: 5) - .rightToView(senderNameView, offset: 0) - .bottom(10) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -// MARK: - 收到的图片消息 -final class ImageReceivedMsgCell: UITableViewCell { - var menuAnchorView: UIView { photoView } - var onImageTap: (() -> Void)? - - func configure(_ msg: ChatMessage) { - timeLabel.isHidden = !msg.showTime - timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil - avatarView.image = msg.avatar - senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) - if !msg.imageUrl.isEmpty { - photoView.dl.setImage(with: msg.imageUrl) + if isOutgoing { + photoView.addSubview(loadingView) + loadingView.layoutChain.centerX().centerY() + avatarView.layoutChain + .topToBottomOfView(timeLabel, offset: 10) + .right(12).width(38).height(38) + senderNameView.layoutChain + .topToView(avatarView) + .rightToLeftOfView(avatarView, offset: -9) + photoView.layoutChain + .topToBottomOfView(senderNameView, offset: 5) + .rightToView(senderNameView, offset: 0) + .width(ChatImageLayout.fallback.width) + .height(ChatImageLayout.fallback.height) + .bottom(10) + } else { + loadingView.isHidden = true + avatarView.layoutChain + .topToBottomOfView(timeLabel, offset: 10) + .left(12).width(38).height(38) + senderNameView.layoutChain + .topToView(avatarView) + .leftToRightOfView(avatarView, offset: 9) + photoView.layoutChain + .topToBottomOfView(senderNameView, offset: 5) + .leftToView(senderNameView) + .width(ChatImageLayout.fallback.width) + .height(ChatImageLayout.fallback.height) + .bottom(10) } - photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight) } - @objc private func onTap() { onImageTap?() } - - private let timeLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 12) - label.textColor = UIColor(hexStr: "#999999") - label.textAlignment = .center - return label - }() - - let avatarView: UIImageView = { - let iv = UIImageView() - iv.contentMode = .scaleAspectFill - iv.cornerRadius = 10 - iv.clipsToBounds = true - iv.backgroundColor = UIColor(hexStr: "#E0E0E0") -// iv.borderWidth = 2 -// iv.borderColor = .white - return iv - }() - - private let senderNameView = ChatSenderNameView() - - lazy var photoView: UIImageView = { - let iv = UIImageView() - iv.contentMode = .scaleAspectFill - iv.cornerRadius = 8 - iv.clipsToBounds = true - iv.backgroundColor = UIColor(hexStr: "#F0F0F0") - iv.isUserInteractionEnabled = true - return iv - }() - - override init(style: CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none - backgroundColor = .clear - contentView.addSubview(timeLabel) - contentView.addSubview(senderNameView) - contentView.addSubview(photoView) - contentView.addSubview(avatarView) - - let tap = UITapGestureRecognizer(target: self, action: #selector(onTap)) - photoView.addGestureRecognizer(tap) - - timeLabel.layoutChain.top().centerX() - - avatarView.layoutChain - .topToBottomOfView(timeLabel, offset: 10) - .left(12).width(38).height(38) - - senderNameView.translatesAutoresizingMaskIntoConstraints = false - senderNameView.layoutChain - .topToView(avatarView) - .leftToRightOfView(avatarView, offset: 9) - - photoView.layoutChain - .topToBottomOfView(senderNameView, offset: 5) - .leftToView(senderNameView) - .width(160).height(160) - .bottom(10) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + override func prepareForReuse() { + super.prepareForReuse() + photoView.image = nil + onImageTap = nil + onNeedRelayout = nil + configuredId = nil + loadingView.stopAnimating() + loadingView.isHidden = true } } diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift index 69697150..bc7985bd 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift @@ -713,28 +713,15 @@ final class GroupChatViewModel { var isAtAll = false var location: ChatLocationPayload? - let maxWH: CGFloat = 200 - var msgImageW: CGFloat = 0 - var msgImageH: CGFloat = 0 - if let width = msg.pictureElem?.sourcePicture?.width, - let height = msg.pictureElem?.bigPicture?.height { - if width >= height { - msgImageW = maxWH - msgImageH = maxWH * CGFloat(height) / CGFloat(width) - } else { - msgImageH = maxWH - msgImageW = maxWH * CGFloat(width) / CGFloat(height) - } - } - let type = msg.contentType.rawValue if type == 103, let sound = msg.soundElem { content = "\(sound.duration)" voiceUrl = sound.sourceUrl ?? "" } else if type == 102, let pic = msg.pictureElem { - imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? "" - imageW = msgImageW - imageH = msgImageH + let layout = Self.pictureLayout(from: pic) + imageUrl = layout.url + imageW = layout.size.width + imageH = layout.size.height } else if type == 109, let elem = msg.locationElem { location = ChatLocationPayload.decode(description: elem.desc, latitude: elem.latitude, @@ -757,9 +744,10 @@ final class GroupChatViewModel { content = "\(sound.duration)" voiceUrl = sound.sourceUrl ?? "" } else if let pic = msg.pictureElem { - imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? "" - imageW = msgImageW - imageH = msgImageH + let layout = Self.pictureLayout(from: pic) + imageUrl = layout.url + imageW = layout.size.width + imageH = layout.size.height } else { content = msg.textElem?.content ?? "" } @@ -790,6 +778,42 @@ final class GroupChatViewModel { ) } + /// 用原图/大图的真实宽高计算气泡。对方消息常只带 snapshot,URL 为空字符串时不能用 `??` 跳过。 + private static func pictureLayout(from pic: OIMPictureElem) -> (url: String, size: CGSize) { + let sourceURL = nonEmpty(pic.sourcePicture?.url) + let bigURL = nonEmpty(pic.bigPicture?.url) + let path = nonEmpty(pic.sourcePath) + let snapshotURL = nonEmpty(pic.snapshotPicture?.url) + + let url = sourceURL ?? bigURL ?? path ?? snapshotURL ?? "" + let usingSnapshotOnly = sourceURL == nil && bigURL == nil && path == nil && snapshotURL != nil + + let sizePairs: [(CGFloat, CGFloat)] + if usingSnapshotOnly { + sizePairs = [(pic.snapshotPicture?.width ?? 0, pic.snapshotPicture?.height ?? 0)] + } else { + sizePairs = [ + (pic.sourcePicture?.width ?? 0, pic.sourcePicture?.height ?? 0), + (pic.bigPicture?.width ?? 0, pic.bigPicture?.height ?? 0) + ] + } + for (w, h) in sizePairs where w > 1 && h > 1 { + return (url, ChatImageLayout.displaySize(width: w, height: h)) + } + if let path, let image = UIImage(contentsOfFile: path), + image.size.width > 1, image.size.height > 1 { + return (url.isEmpty ? path : url, + ChatImageLayout.displaySize(width: image.size.width, height: image.size.height)) + } + return (url, ChatImageLayout.fallback) + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + private func makeQuotePreview(from msg: OIMMessageInfo) -> QuotePreview { let id = msg.clientMsgID let name = msg.senderNickname ?? getUserNickName(id: msg.sendID ?? "") diff --git a/QuickLocation/Section/Group/GroupView.swift b/QuickLocation/Section/Group/GroupView.swift index 7b022b78..13dca9c3 100644 --- a/QuickLocation/Section/Group/GroupView.swift +++ b/QuickLocation/Section/Group/GroupView.swift @@ -462,6 +462,7 @@ class GroupView: UIView { tv.showsVerticalScrollIndicator = false tv.register(CircleGroupCell.self) tv.estimatedRowHeight = 70 + tv.rowHeight = 70 tv.bounces = true tv.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 97 + kSafeBottomMargin, right: 0) return tv @@ -474,6 +475,7 @@ class GroupView: UIView { tv.showsVerticalScrollIndicator = false tv.register(CircleGroupCell.self) tv.estimatedRowHeight = 70 + tv.rowHeight = 70 tv.bounces = true tv.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 97 + kSafeBottomMargin, right: 0) return tv @@ -650,23 +652,26 @@ final class CircleGroupCell: UITableViewCell { private let nameLabel: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 14, weight: .medium) - label.textColor = ThemeManager.shared.color.titleAuxColor + label.font = .systemFont(ofSize: 16, weight: .medium) + label.textColor = UIColor(hexStr: "#2A3648") + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return label }() - private let countLabel: UILabel = { + private let previewLabel: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 10, weight: .medium) - label.textColor = UIColor(hexStr: "#999999") + label.font = .systemFont(ofSize: 12, weight: .medium) + label.textColor = UIColor(hexStr: "#AAAAAA") + label.lineBreakMode = .byTruncatingTail return label }() lazy var unreadBadgeView: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#FF383C") - view.cornerRadius = 7 + view.cornerRadius = 8 view.isHidden = true + view.clipsToBounds = true return view }() @@ -674,65 +679,75 @@ final class CircleGroupCell: UITableViewCell { let label = UILabel() label.font = .systemFont(ofSize: 10, weight: .medium) label.textColor = .white + label.textAlignment = .center return label }() private let timeLabel: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 10, weight: .regular) - label.textColor = UIColor(hexStr: "#999999") + label.font = .systemFont(ofSize: 10, weight: .medium) + label.textColor = UIColor(hexStr: "#AAAAAA") label.textAlignment = .right + label.setContentCompressionResistancePriority(.required, for: .horizontal) return label }() - private let dividerLine: UIView = { - let v = UIView() - v.backgroundColor = UIColor(hexStr: "#F0F0F0") - return v + private let tagStack: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.alignment = .center + view.spacing = 4 + view.setContentCompressionResistancePriority(.required, for: .horizontal) + return view }() override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .white + contentView.clipsToBounds = false + clipsToBounds = false contentView.addSubview(iconView) - contentView.addSubview(nameLabel) - contentView.addSubview(countLabel) contentView.addSubview(unreadBadgeView) unreadBadgeView.addSubview(unreadBadge) + contentView.addSubview(nameLabel) contentView.addSubview(timeLabel) - contentView.addSubview(dividerLine) + contentView.addSubview(previewLabel) + contentView.addSubview(tagStack) iconView.layoutChain - .left(15).edgesVertical(15) - .width(40).height(40) - - nameLabel.layoutChain - .topToView(iconView, offset: 4) - .leftToRightOfView(iconView, offset: 12) - .right(90) - - countLabel.layoutChain - .topToBottomOfView(nameLabel, offset: 4) - .leftToView(nameLabel) + .left(15) + .centerY() + .width(42) + .height(42) unreadBadgeView.layoutChain - .centerY(nameLabel) - .right(15) + .topToView(iconView, offset: -4) + .rightToView(iconView, offset: 6) + .height(16) unreadBadge.layoutChain - .edgesHorzontal(8) - .edgesVertical(1) + .edgesHorzontal(5) + .centerY() timeLabel.layoutChain + .topToView(iconView, offset: 2) .right(15) - .centerY(countLabel) - dividerLine.layoutChain - .edgesHorzontal(15) - .bottom() - .height(0.5) + nameLabel.layoutChain + .topToView(iconView, offset: 2) + .leftToRightOfView(iconView, offset: 12) + .rightToLeftOfView(timeLabel, offset: -8) + + tagStack.layoutChain + .right(15) + .centerY(previewLabel) + + previewLabel.layoutChain + .topToBottomOfView(nameLabel, offset: 4) + .leftToView(nameLabel) + .rightToLeftOfView(tagStack, offset: -8) } required init?(coder: NSCoder) { @@ -742,10 +757,10 @@ final class CircleGroupCell: UITableViewCell { func configure(_ model: GroupCellData) { let g = model.groupInfo iconView.image = model.localIcon - nameLabel.text = g.groupName ?? "" - countLabel.text = "共\(g.memberCount)个成员" + let count = g.memberCount + nameLabel.text = "\(g.groupName ?? "") (\(count))" + previewLabel.text = model.lastMsgPreview - // 未读消息数 if model.unreadCount > 0 { unreadBadgeView.isHidden = false unreadBadge.text = model.unreadCount > 99 ? "99+" : "\(model.unreadCount)" @@ -753,13 +768,48 @@ final class CircleGroupCell: UITableViewCell { unreadBadgeView.isHidden = true } - // 最后消息时间 if model.lastMsgTime > 0 { let date = Date(timeIntervalSince1970: TimeInterval(model.lastMsgTime) / 1000) timeLabel.text = date.formatForIM() } else { timeLabel.text = "" } + + tagStack.arrangedSubviews.forEach { + tagStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + let tags = Array(model.tags.prefix(2)) + tagStack.isHidden = tags.isEmpty + for text in tags { + tagStack.addArrangedSubview(Self.makeTagLabel(text)) + } + } + + private static func makeTagLabel(_ text: String) -> UILabel { + let label = CircleGroupTagLabel() + label.text = text + label.textColor = UIColor(hexStr: "#00ADFE") + label.font = .systemFont(ofSize: 10, weight: .medium) + label.backgroundColor = UIColor(hexStr: "#E3F6FF") + label.cornerRadius = 4 + label.clipsToBounds = true + label.textAlignment = .center + return label + } +} + +private final class CircleGroupTagLabel: UILabel { + private let inset = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6) + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: inset)) + } + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize(width: size.width + inset.left + inset.right, + height: size.height + inset.top + inset.bottom) } } diff --git a/QuickLocation/Section/Group/GroupViewModel.swift b/QuickLocation/Section/Group/GroupViewModel.swift index bf94a550..ac5affd1 100644 --- a/QuickLocation/Section/Group/GroupViewModel.swift +++ b/QuickLocation/Section/Group/GroupViewModel.swift @@ -18,6 +18,8 @@ struct GroupCellData { let groupInfo: OIMGroupInfo let unreadCount: Int let lastMsgTime: Int // ms timestamp + let lastMsgPreview: String + let tags: [String] let localIcon: UIImage } @@ -96,6 +98,8 @@ final class GroupViewModel { groupInfo: g, unreadCount: Int(conv?.unreadCount ?? 0), lastMsgTime: Int(conv?.latestMsgSendTime ?? 0), + lastMsgPreview: Self.previewText(from: conv?.latestMsg), + tags: Array((groupList.first { $0.group_key == g.groupID }?.labels ?? []).prefix(2)), localIcon: getLocalIcon(groupId: g.groupID ?? "") ) all.append(data) @@ -108,6 +112,32 @@ final class GroupViewModel { joinedSectionsSubject.onNext(all.mapSection()) } + private static func previewText(from msg: OIMMessageInfo?) -> String { + guard let msg else { return "" } + let body: String + switch msg.contentType { + case .image: body = "[图片]" + case .audio: body = "[语音]" + case .video: body = "[视频]" + case .file: body = "[文件]" + case .location: body = "[位置]" + case .at: body = Self.displayBody(msg.atTextElem?.text ?? "[消息]") + case .quote: body = Self.displayBody(msg.quoteElem?.text ?? "[消息]") + case .face: body = "[表情]" + default: + body = Self.displayBody(msg.textElem?.content ?? msg.content ?? "") + } + let name = msg.senderNickname ?? "" + if name.isEmpty { return body } + if body.isEmpty { return name } + return "\(name): \(body)" + } + + private static func displayBody(_ text: String) -> String { + if text.hasPrefix("js_emoji:") { return "[表情]" } + return text + } + // MARK: - Init init() { output = Output( diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index 82e11ce4..2bf96287 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -1255,6 +1255,26 @@ extension HomeViewController { if let view = mapView.view(for: selectedAnn) { raiseAnnotationView(view) } + refreshAnnotationMoods() + } + + private func moodIndex(for member: CircleMember) -> Int { + if member.isCurrentUser { + return AppContextManager.shared.account?.mood ?? 0 + } + return viewModel.memberList.first(where: { $0.user_id == member.id })?.mood ?? 0 + } + + private func refreshAnnotationMoods() { + guard let mapView = rootView.mapView else { return } + for annotation in mapView.annotations ?? [] { + guard let memberAnn = annotation as? MemberAnnotation, + let view = mapView.view(for: memberAnn) as? MemberAnnotationView else { continue } + view.setMood( + index: moodIndex(for: memberAnn.member), + selected: isSelectedAnnotation(memberAnn, userId: selectedMemberId) + ) + } } #endif /// 当前用户地图点 id 是 "current",MQTT 里是真实 user_id @@ -1390,7 +1410,15 @@ extension HomeViewController: MAMapViewDelegate { annotationView?.annotation = memberAnnotation } + let selectedIds = annotationIds(matching: selectedMemberId) + let isSelected = selectedIds.contains(memberAnnotation.member.id) + || (memberAnnotation.member.isCurrentUser + && (selectedIds.contains("current") || selectedIds.contains(AppContextManager.shared.userId))) annotationView?.configure(with: memberAnnotation.member) + annotationView?.setMood( + index: moodIndex(for: memberAnnotation.member), + selected: isSelected + ) annotationView?.onTap = { [weak self] ann in self?.selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false) } @@ -1399,9 +1427,7 @@ extension HomeViewController: MAMapViewDelegate { if memberAnnotation.member.isCurrentUser { annotationView?.updateHeading(currentHeading) } - let selectedIds = annotationIds(matching: selectedMemberId) - if selectedIds.contains(memberAnnotation.member.id) - || (memberAnnotation.member.isCurrentUser && (selectedIds.contains("current") || selectedIds.contains(AppContextManager.shared.userId))) { + if isSelected { annotationView?.zIndex = 10 } else if memberAnnotation.member.isCurrentUser { annotationView?.zIndex = 1 diff --git a/QuickLocation/Section/Map/MemberAnnotationView.swift b/QuickLocation/Section/Map/MemberAnnotationView.swift index 00573284..a3fee5ef 100644 --- a/QuickLocation/Section/Map/MemberAnnotationView.swift +++ b/QuickLocation/Section/Map/MemberAnnotationView.swift @@ -6,6 +6,7 @@ import Foundation #if !targetEnvironment(simulator) import AMapNaviKit +import Lottie final class MemberAnnotationView: MAAnnotationView { @@ -13,7 +14,7 @@ final class MemberAnnotationView: MAAnnotationView { var onTap: ((MemberAnnotation) -> Void)? // MARK: - Design Constants - private static let bubbleSize: CGFloat = 56 + private static let bubbleSize: CGFloat = 66 private static let bubbleCorner: CGFloat = 14 private static let borderWidth: CGFloat = 4 private static let pointerWidth: CGFloat = 14 @@ -22,8 +23,10 @@ final class MemberAnnotationView: MAAnnotationView { private static let gapNameToBubble: CGFloat = 4 private static let nameTagHeight: CGFloat = 20 private static let nameTagHPadding: CGFloat = 8 - private static let dotOuterSize: CGFloat = 12 - private static let dotInnerSize: CGFloat = 10 + private static let moodSize: CGFloat = 36 + private static let gapMoodToName: CGFloat = 2 + private static let dotOuterSize: CGFloat = 15 + private static let dotInnerSize: CGFloat = 12 private static let currentUserDotColor = UIColor(hexStr: "#5ED4FF") private static let memberDotPalette: [UIColor] = [ @@ -42,9 +45,22 @@ final class MemberAnnotationView: MAAnnotationView { ] private var showsNameTag = false + private var showsMood = false + private var playingMoodName: String? + private var finishedMoodName: String? private let containerView = UIView() + private let moodView: LottieAnimationView = { + let view = LottieAnimationView() + view.contentMode = .scaleAspectFit + view.loopMode = .playOnce + view.backgroundBehavior = .pauseAndRestore + view.isHidden = true + view.isUserInteractionEnabled = false + return view + }() + private let nameTagView: UIView = { let v = UIView() v.backgroundColor = .white @@ -113,7 +129,7 @@ final class MemberAnnotationView: MAAnnotationView { private let dotCenterView: UIView = { let v = UIView() v.backgroundColor = .white - v.layer.cornerRadius = 2 + v.layer.cornerRadius = 3 return v }() @@ -136,6 +152,7 @@ final class MemberAnnotationView: MAAnnotationView { addSubview(containerView) containerView.isUserInteractionEnabled = true + containerView.addSubview(moodView) containerView.addSubview(nameTagView) nameTagView.addSubview(nameLabel) containerView.addSubview(bubbleView) @@ -174,12 +191,53 @@ final class MemberAnnotationView: MAAnnotationView { layoutMarker() } + func setMood(index: Int, selected: Bool) { + let item = selected ? MoodStatusCatalog.item(moodIndex: index) : nil + guard let item, let path = item.jsonPath else { + stopMood() + finishedMoodName = nil + layoutMarker() + return + } + if finishedMoodName == item.jsonName { + layoutMarker() + return + } + if playingMoodName == item.jsonName, moodView.animation != nil { + layoutMarker() + return + } + showsMood = true + moodView.isHidden = false + playingMoodName = item.jsonName + moodView.stop() + moodView.animation = LottieAnimation.filepath(path) + moodView.currentProgress = 0 + moodView.play { [weak self] completed in + guard let self, completed else { return } + self.finishedMoodName = item.jsonName + self.stopMood() + self.layoutMarker() + } + layoutMarker() + } + + private func stopMood() { + showsMood = false + playingMoodName = nil + moodView.stop() + moodView.animation = nil + moodView.isHidden = true + } + override func prepareForReuse() { super.prepareForReuse() showsNameTag = false nameTagView.isHidden = true nameLabel.text = nil onTap = nil + finishedMoodName = nil + stopMood() layoutMarker() } @@ -203,8 +261,10 @@ final class MemberAnnotationView: MAAnnotationView { let gap = Self.gapBubbleToDot let dot = Self.dotOuterSize + let moodH: CGFloat = showsMood ? Self.moodSize : 0 + let moodGap: CGFloat = showsMood ? Self.gapMoodToName : 0 let nameH: CGFloat = showsNameTag ? Self.nameTagHeight : 0 - let nameGap: CGFloat = showsNameTag ? Self.gapNameToBubble : 0 + let nameGap: CGFloat = showsNameTag ? Self.gapNameToBubble : (showsMood ? 2 : 0) var nameTagWidth: CGFloat = 0 if showsNameTag { @@ -213,24 +273,33 @@ final class MemberAnnotationView: MAAnnotationView { nameTagWidth = min(max(textWidth + Self.nameTagHPadding * 2, 44), 120) } - let totalWidth = max(bubble, nameTagWidth) - let totalHeight = nameH + nameGap + bubble + pointerH + gap + dot + let totalWidth = max(bubble, nameTagWidth, showsMood ? Self.moodSize : 0) + let totalHeight = moodH + moodGap + nameH + nameGap + bubble + pointerH + gap + dot bounds = CGRect(x: 0, y: 0, width: totalWidth, height: totalHeight) containerView.frame = bounds centerOffset = CGPoint(x: 0, y: -(totalHeight / 2 - Self.dotOuterSize / 2)) + if showsMood { + moodView.frame = CGRect( + x: (totalWidth - Self.moodSize) / 2, + y: 0, + width: Self.moodSize, + height: Self.moodSize + ) + } + if showsNameTag { nameTagView.frame = CGRect( x: (totalWidth - nameTagWidth) / 2, - y: 0, + y: moodH + moodGap, width: nameTagWidth, height: Self.nameTagHeight ) nameLabel.frame = nameTagView.bounds.insetBy(dx: Self.nameTagHPadding - 2, dy: 0) } - let bubbleY = nameH + nameGap + let bubbleY = moodH + moodGap + nameH + nameGap bubbleView.frame = CGRect( x: (totalWidth - bubble) / 2, y: bubbleY, @@ -262,7 +331,7 @@ final class MemberAnnotationView: MAAnnotationView { x: inset, y: inset, width: Self.dotInnerSize, height: Self.dotInnerSize ) - let centerSize: CGFloat = 4 + let centerSize: CGFloat = 6 let centerInset = (Self.dotInnerSize - centerSize) / 2 dotCenterView.frame = CGRect( x: centerInset, y: centerInset, diff --git a/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift b/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift index 458eba10..88694913 100644 --- a/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift +++ b/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift @@ -6,7 +6,7 @@ import UIKit import RxSwift import RxCocoa -import HXPHPicker + final class FeedbackVC: BaseViewController { @@ -98,19 +98,14 @@ final class FeedbackVC: BaseViewController { private func showAlbum() { let remainingCount = 3 - selectedImages.count guard remainingCount > 0, !isSubmitting else { return } - - let config = PhotoTools.getWXPickerConfig() - config.selectOptions = [.photo] - config.selectMode = .multiple - config.maximumSelectedCount = remainingCount - config.allowSyncICloudWhenSelectPhoto = false - config.previewView.bottomView.editButtonHidden = true - config.photoList.allowAddCamera = false - - let pickerController = PhotoPickerController(picker: config) - pickerController.pickerDelegate = self - pickerController.modalPresentationStyle = .fullScreen - present(pickerController, animated: true) + let picker = PhotoPickerVC(maxCount: remainingCount) + picker.onConfirm = { [weak self] images in + guard let self else { return } + self.selectedImages.append(contentsOf: images.prefix(remainingCount)) + self.selectedImages = Array(self.selectedImages.prefix(3)) + self.rootView.updateImages(self.selectedImages) + } + present(picker, animated: true) } private func submit() { @@ -144,25 +139,4 @@ final class FeedbackVC: BaseViewController { } } -extension FeedbackVC: PhotoPickerControllerDelegate { - func pickerController( - _ pickerController: PhotoPickerController, - didFinishSelection result: PickerResult - ) { - let remainingCount = max(0, 3 - selectedImages.count) - guard remainingCount > 0 else { return } - - result.getImage { _, _, _ in - } completionHandler: { [weak self] images in - DispatchQueue.main.async { - guard let self else { return } - self.selectedImages.append(contentsOf: images.prefix(remainingCount)) - self.selectedImages = Array(self.selectedImages.prefix(3)) - self.rootView.updateImages(self.selectedImages) - } - } - } - - func pickerController(didCancel pickerController: PhotoPickerController) {} -} diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift index eeebba16..7396890b 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift @@ -24,17 +24,6 @@ final class PigeonMessageVC: BaseViewController { private var recordedDuration: TimeInterval = 0 private var isPressingRecord = false - private lazy var imagePicker: ImagePicker = { - let picker = ImagePicker() - picker.imageDidPicked = { [weak self] image in - DispatchQueue.main.async { - self?.selectedImage = image - self?.rootView.setSelectedImage(image) - } - } - return picker - }() - override func loadView() { rootView = PigeonMessageView(frame: UIScreen.main.bounds) view = rootView @@ -144,7 +133,13 @@ final class PigeonMessageVC: BaseViewController { } @objc private func selectImage() { - imagePicker.presentImagePickerPopup() + let picker = PhotoPickerVC(maxCount: 1) + picker.onConfirm = { [weak self] images in + guard let image = images.first else { return } + self?.selectedImage = image + self?.rootView.setSelectedImage(image) + } + present(picker, animated: true) } @objc private func deleteImage() { diff --git a/QuickLocation/Section/Scan/ScanVC.swift b/QuickLocation/Section/Scan/ScanVC.swift index a0ce7d6e..bcc62163 100644 --- a/QuickLocation/Section/Scan/ScanVC.swift +++ b/QuickLocation/Section/Scan/ScanVC.swift @@ -2,8 +2,6 @@ // ScanVC.swift // QuickLocation // -// Created by 八条 on 2026/6/2. -// import UIKit import AVFoundation @@ -12,48 +10,41 @@ import RxSwift class ScanVC: BaseViewController { override var isNavigationBarHidden: Bool { true } + override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } - fileprivate var rootView: ScanView! + private var rootView: CameraCaptureView! + private let scanDoneAction: ((String) -> Void)? + private var isHandling = false override func loadView() { - rootView = ScanView(frame: UIScreen.main.bounds) + rootView = CameraCaptureView(mode: .scan) view = rootView } - private let scanDoneAction: ((String) -> Void)? - - private var captureSession: AVCaptureSession! - private var previewLayer: AVCaptureVideoPreviewLayer! - private var isScanning = false - override func viewDidLoad() { super.viewDidLoad() - setupCamera() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - previewLayer?.frame = view.bounds + rootView.onClose = { [weak self] in + self?.closeCapture() + } + rootView.onScan = { [weak self] text in + self?.handleScanResult(text: text) + } + rootView.onOpenAlbum = { [weak self] in + self?.openAlbum() + } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - if let session = captureSession, !session.isRunning { - DispatchQueue.global(qos: .background).async { - session.startRunning() - } - } - startScanAnimation() + isHandling = false + rootView.startSession() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - if let session = captureSession, session.isRunning { - session.stopRunning() - } - rootView.scanLineView.layer.removeAllAnimations() + rootView.stopSession() } - + init(scanDoneAction: ((String) -> Void)? = nil) { self.scanDoneAction = scanDoneAction super.init(nibName: nil, bundle: nil) @@ -63,123 +54,75 @@ class ScanVC: BaseViewController { fatalError("init(coder:) has not been implemented") } - private func handleScanResult(text: String) { - guard !isScanning else { return } - isScanning = true - captureSession.stopRunning() - rootView.scanLineView.alpha = 0 - rootView.scanLineView.layer.removeAllAnimations() - if let scanDoneAction = scanDoneAction { - scanDoneAction(text) - AppRouter.shared.popOrDismiss() + private func closeCapture() { + if presentingViewController != nil { + dismiss(animated: true) + } else { + navigationController?.popViewController(animated: true) } -// requestJoinGroup(code: text) } - - // MARK: - API + + private func handleScanResult(text: String) { + guard !isHandling else { return } + isHandling = true + rootView.stopSession() + if let scanDoneAction { + scanDoneAction(text) + closeCapture() + return + } + requestJoinGroup(code: text) + } + private func requestJoinGroup(code: String) { - dl.showLoading() - GroupService.operate(opType: "join", requestData: ["share_code" : code]).subscribe(onNext: { response in - self.dl.show(text: "申请成功") { + DLToast.showLoading() + GroupService.operate(opType: "join", requestData: ["share_code": code]).subscribe(onNext: { response in + DLToast.showSuccess(text: response.message ?? "申请成功") { AppRouter.shared.popOrDismiss() } - }, onError: { [weak self] (error) in - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - self?.isScanning = false - self?.startSession() + }, onError: { [weak self] _ in + DLToast.showError(text: "无法识别该二维码") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self?.isHandling = false + self?.rootView.startSession() } }).disposed(by: disposeBag) } - - private func startSession() { - if let session = captureSession, !session.isRunning { - DispatchQueue.global(qos: .background).async { - session.startRunning() - } - } - startScanAnimation() + + private func openAlbum() { + guard UIImagePickerController.isSourceTypeAvailable(.photoLibrary) else { return } + let picker = UIImagePickerController() + picker.sourceType = .photoLibrary + picker.delegate = self + present(picker, animated: true) } - // MARK: - 扫描线动画 - private func startScanAnimation() { - rootView.scanLineView.layer.removeAllAnimations() - rootView.scanLineView.alpha = 1 - rootView.scanLineView.frame.origin.y = 0 - - let boxH = rootView.scanBoxView.bounds.height - guard boxH > 0 else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.startScanAnimation() - } + private func decodeQR(from image: UIImage) { + guard let ciImage = CIImage(image: image) else { + DLToast.show(text: "无法识别二维码") + isHandling = false return } - - UIView.animate(withDuration: 2.0, delay: 0, options: [.repeat, .curveLinear]) { - self.rootView.scanLineView.frame.origin.y = boxH - 2 - } - } - - // MARK: - 相机配置 - private func setupCamera() { - let status = AVCaptureDevice.authorizationStatus(for: .video) - switch status { - case .authorized: - break - case .notDetermined: - AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in - DispatchQueue.main.async { - if granted { self?.setupCamera() } - } - } - return - default: - DLToast.show(text: "请在设置中开启相机权限") - return - } - - captureSession = AVCaptureSession() - captureSession.sessionPreset = .high - - guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { - DLToast.show(text: "未检测到摄像头") - return - } - - do { - let input = try AVCaptureDeviceInput(device: videoDevice) - if captureSession.canAddInput(input) { - captureSession.addInput(input) - } - - let output = AVCaptureMetadataOutput() - if captureSession.canAddOutput(output) { - captureSession.addOutput(output) - output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) - output.metadataObjectTypes = [.qr, .code128, .ean13, .ean8, .upce, .code39, .code39Mod43] - } - - previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) - previewLayer.frame = view.bounds - previewLayer.videoGravity = .resizeAspectFill - view.layer.insertSublayer(previewLayer, at: 0) - - DispatchQueue.global(qos: .background).async { - self.captureSession.startRunning() - } - - } catch { - DLToast.show(text: "相机初始化失败:\(error.localizedDescription)") + let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]) + let features = detector?.features(in: ciImage) ?? [] + if let qr = features.first as? CIQRCodeFeature, let text = qr.messageString, !text.isEmpty { + handleScanResult(text: text) + } else { + DLToast.show(text: "未识别到二维码") + isHandling = false } } } -// MARK: - AVCaptureMetadataOutputObjectsDelegate -extension ScanVC: AVCaptureMetadataOutputObjectsDelegate { - func metadataOutput(_ output: AVCaptureMetadataOutput, - didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection) { - guard let obj = metadataObjects.first as? AVMetadataMachineReadableCodeObject, - let result = obj.stringValue else { return } - handleScanResult(text: result) +extension ScanVC: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + picker.dismiss(animated: true) + if let image = info[.originalImage] as? UIImage { + decodeQR(from: image) + } } }