diff --git a/Pods/TagListView/TagListView/TagListView.swift b/Pods/TagListView/TagListView/TagListView.swift index 821249b5..5854dc3f 100755 --- a/Pods/TagListView/TagListView/TagListView.swift +++ b/Pods/TagListView/TagListView/TagListView.swift @@ -79,10 +79,10 @@ open class TagListView: UIView { } } - @IBInspectable open dynamic var borderColor: UIColor? { + @IBInspectable open dynamic var tagBorderColor: UIColor? { didSet { tagViews.forEach { - $0.borderColor = borderColor + $0.borderColor = tagBorderColor } } } @@ -338,7 +338,7 @@ open class TagListView: UIView { tagView.titleLineBreakMode = tagLineBreakMode tagView.cornerRadius = cornerRadius tagView.borderWidth = borderWidth - tagView.borderColor = borderColor + tagView.borderColor = tagBorderColor tagView.selectedBorderColor = selectedBorderColor tagView.paddingX = paddingX tagView.paddingY = paddingY diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index 0a1665ab..33e8c25b 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -323,10 +323,16 @@ 55B218A13024A0010078470C /* MemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A0010078470B /* MemberInfoView.swift */; }; 55B218B13024B00100784722 /* FeatureIntroVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218B13024B00100784721 /* FeatureIntroVC.swift */; }; 55B218B13024B00100784724 /* FeatureIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218B13024B00100784723 /* FeatureIntroView.swift */; }; + 55B219A2302A000100784751 /* UnlockRequestPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219A1302A000100784750 /* UnlockRequestPopView.swift */; }; + 55B219B2302A000100784761 /* ReceiveMessagePopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219B1302A000100784760 /* ReceiveMessagePopView.swift */; }; 55B219C13024C00100784722 /* BubbleHeroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219C13024C00100784721 /* BubbleHeroView.swift */; }; 55B219C13024C00100784724 /* CreateBubbleSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219C13024C00100784723 /* CreateBubbleSetupView.swift */; }; 55B219C13024C00100784726 /* BubbleKnowledgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219C13024C00100784725 /* BubbleKnowledgeView.swift */; }; 55B219C13024C00100784728 /* BubbleKnowledgeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219C13024C00100784727 /* BubbleKnowledgeVC.swift */; }; + 55B219C2302A000100784771 /* PigeonMessageVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219C1302A000100784770 /* PigeonMessageVC.swift */; }; + 55B219C4302A000100784773 /* PigeonMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219C3302A000100784772 /* PigeonMessageView.swift */; }; + 55B21A02302B0001007847A2 /* SOSAnimationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B21A01302B0001007847A1 /* SOSAnimationViews.swift */; }; + 55B21A04302B0001007847A4 /* SOSReceiveAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B21A03302B0001007847A3 /* SOSReceiveAlertView.swift */; }; 55B21D6E3023117900784774 /* 优设标题黑_猫啃网.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */; }; 55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */; }; 55BF75352FFF91690055DA57 /* InteractionEmojiCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */; }; @@ -727,10 +733,16 @@ 55B218A13024A0010078470B /* MemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberInfoView.swift; sourceTree = ""; }; 55B218B13024B00100784721 /* FeatureIntroVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIntroVC.swift; sourceTree = ""; }; 55B218B13024B00100784723 /* FeatureIntroView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIntroView.swift; sourceTree = ""; }; + 55B219A1302A000100784750 /* UnlockRequestPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockRequestPopView.swift; sourceTree = ""; }; + 55B219B1302A000100784760 /* ReceiveMessagePopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiveMessagePopView.swift; sourceTree = ""; }; 55B219C13024C00100784721 /* BubbleHeroView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubbleHeroView.swift; sourceTree = ""; }; 55B219C13024C00100784723 /* CreateBubbleSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateBubbleSetupView.swift; sourceTree = ""; }; 55B219C13024C00100784725 /* BubbleKnowledgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubbleKnowledgeView.swift; sourceTree = ""; }; 55B219C13024C00100784727 /* BubbleKnowledgeVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubbleKnowledgeVC.swift; sourceTree = ""; }; + 55B219C1302A000100784770 /* PigeonMessageVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonMessageVC.swift; sourceTree = ""; }; + 55B219C3302A000100784772 /* PigeonMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonMessageView.swift; sourceTree = ""; }; + 55B21A01302B0001007847A1 /* SOSAnimationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOSAnimationViews.swift; sourceTree = ""; }; + 55B21A03302B0001007847A3 /* SOSReceiveAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOSReceiveAlertView.swift; sourceTree = ""; }; 55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "优设标题黑_猫啃网.ttf"; sourceTree = ""; }; 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionPopView.swift; sourceTree = ""; }; 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionEmojiCell.swift; sourceTree = ""; }; @@ -758,29 +770,21 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 30CCDF8E2FE3E63B00F5214A /* sound */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = sound; sourceTree = ""; }; 30CCDF902FE3E63B00F5214A /* video */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = video; sourceTree = ""; }; 30CCE01E2FE3E64700F5214A /* lotties */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = lotties; sourceTree = ""; }; 30D87CEF2FDFF52100E958FD /* TTGTagCollectionView */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = TTGTagCollectionView; sourceTree = ""; }; @@ -1215,12 +1219,14 @@ 305A762A2FCA8C7000227D26 /* Home */ = { isa = PBXGroup; children = ( - 305A76272FCA8C7000227D26 /* GroupMemberView.swift */, 305A76292FCA8C7000227D26 /* HomeViewController.swift */, 305A798B2FCAB99300227D26 /* HomeViewModel.swift */, 305A76282FCA8C7000227D26 /* HomeView.swift */, + 305A76272FCA8C7000227D26 /* GroupMemberView.swift */, 55B2179030217D6600784774 /* HomeView2.swift */, 55B2179230218CE100784774 /* GroupMemberView2.swift */, + 55B219A1302A000100784750 /* UnlockRequestPopView.swift */, + 55B219B1302A000100784760 /* ReceiveMessagePopView.swift */, 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */, 55B217C43022F0A000784724 /* MemberAppUsageView.swift */, 55B217DD302301000078473C /* TodayTrackDetail */, @@ -1295,6 +1301,7 @@ 305A76262FCA8C7000227D26 /* Group */, 30D74AAC2FEA13BD0050EB2C /* Schedule */, 55B218B13024B00100784720 /* Explore */, + 55B219C5302A000100784774 /* PigeonMessage */, 305A76392FCA8C7000227D26 /* Mine */, 305A798E2FCAC5F600227D26 /* InviteMember */, 3062E8C52FCFD01000CEF511 /* VipRecharge */, @@ -1711,6 +1718,8 @@ 30CCDE592FE39F9D00F5214A /* SOSViewController.swift */, 30CCDE572FE39F8C00F5214A /* SOSView.swift */, 30CCDE5B2FE3A1A800F5214A /* SOSPracticeView.swift */, + 55B21A01302B0001007847A1 /* SOSAnimationViews.swift */, + 55B21A03302B0001007847A3 /* SOSReceiveAlertView.swift */, ); path = SOS; sourceTree = ""; @@ -1973,6 +1982,15 @@ path = Explore; sourceTree = ""; }; + 55B219C5302A000100784774 /* PigeonMessage */ = { + isa = PBXGroup; + children = ( + 55B219C1302A000100784770 /* PigeonMessageVC.swift */, + 55B219C3302A000100784772 /* PigeonMessageView.swift */, + ); + path = PigeonMessage; + sourceTree = ""; + }; 74B9CD2ABDBC4C8CE3107CDE /* iOS */ = { isa = PBXGroup; children = ( @@ -2328,10 +2346,16 @@ 305A76B42FCA8C7000227D26 /* UINavigationController+FDFullscreenPopGesture.m in Sources */, 305A76B52FCA8C7000227D26 /* UITableView+Extension.swift in Sources */, 30CCDE582FE39F8C00F5214A /* SOSView.swift in Sources */, + 55B21A02302B0001007847A2 /* SOSAnimationViews.swift in Sources */, + 55B21A04302B0001007847A4 /* SOSReceiveAlertView.swift in Sources */, 30EFF3E02FDA9CE300EB35D4 /* EmergencyContactModel.swift in Sources */, 305A76B62FCA8C7000227D26 /* UITextField+Extensions.swift in Sources */, 30EFF3A82FD7C6A400EB35D4 /* GroupSettingViewModel.swift in Sources */, 55B2179130217D6600784774 /* HomeView2.swift in Sources */, + 55B219A2302A000100784751 /* UnlockRequestPopView.swift in Sources */, + 55B219B2302A000100784761 /* ReceiveMessagePopView.swift in Sources */, + 55B219C2302A000100784771 /* PigeonMessageVC.swift in Sources */, + 55B219C4302A000100784773 /* PigeonMessageView.swift in Sources */, 30B819F22FF3CE3C00FAB693 /* ItineraryTraceView.swift in Sources */, 305A76B72FCA8C7000227D26 /* UIView+Extension.swift in Sources */, 305A76B82FCA8C7000227D26 /* UIViewController+Extension.swift in Sources */, diff --git a/QuickLocation/Assets.xcassets/Home/receive_message_card.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/receive_message_card.imageset/Contents.json new file mode 100644 index 00000000..97e90ae3 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/receive_message_card.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "receive_message_card@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "receive_message_card@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/receive_message_card.imageset/receive_message_card@2x.png b/QuickLocation/Assets.xcassets/Home/receive_message_card.imageset/receive_message_card@2x.png new file mode 100644 index 00000000..5c8bc7a4 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/receive_message_card.imageset/receive_message_card@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/receive_message_card.imageset/receive_message_card@3x.png b/QuickLocation/Assets.xcassets/Home/receive_message_card.imageset/receive_message_card@3x.png new file mode 100644 index 00000000..c0eb8547 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/receive_message_card.imageset/receive_message_card@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/receive_message_title.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/receive_message_title.imageset/Contents.json new file mode 100644 index 00000000..f72bc330 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/receive_message_title.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "receive_message_title@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "receive_message_title@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/receive_message_title.imageset/receive_message_title@2x.png b/QuickLocation/Assets.xcassets/Home/receive_message_title.imageset/receive_message_title@2x.png new file mode 100644 index 00000000..04289a13 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/receive_message_title.imageset/receive_message_title@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/receive_message_title.imageset/receive_message_title@3x.png b/QuickLocation/Assets.xcassets/Home/receive_message_title.imageset/receive_message_title@3x.png new file mode 100644 index 00000000..c92fabb8 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/receive_message_title.imageset/receive_message_title@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/Contents.json new file mode 100644 index 00000000..b394b273 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "unlock_receive@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "unlock_receive@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/unlock_receive@2x.png b/QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/unlock_receive@2x.png new file mode 100644 index 00000000..438b3ef5 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/unlock_receive@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/unlock_receive@3x.png b/QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/unlock_receive@3x.png new file mode 100644 index 00000000..e9dda8ef Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/unlock_receive@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/Contents.json new file mode 100644 index 00000000..67bf5811 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "unlock_request@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "unlock_request@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@2x.png b/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@2x.png new file mode 100644 index 00000000..73959e72 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@3x.png b/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@3x.png new file mode 100644 index 00000000..c58ab089 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/unlock_request_popup.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/unlock_request_popup.imageset/Contents.json new file mode 100644 index 00000000..ff1fd3c7 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/unlock_request_popup.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "unlock_request_popup@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "unlock_request_popup@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/unlock_request_popup.imageset/unlock_request_popup@2x.png b/QuickLocation/Assets.xcassets/Home/unlock_request_popup.imageset/unlock_request_popup@2x.png new file mode 100644 index 00000000..49e5f21c Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/unlock_request_popup.imageset/unlock_request_popup@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/unlock_request_popup.imageset/unlock_request_popup@3x.png b/QuickLocation/Assets.xcassets/Home/unlock_request_popup.imageset/unlock_request_popup@3x.png new file mode 100644 index 00000000..3e020206 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/unlock_request_popup.imageset/unlock_request_popup@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/add.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/add.imageset/Contents.json new file mode 100644 index 00000000..feb6bd4b --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/add.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"idiom":"universal","scale":"1x"},{"filename":"add@2x.png","idiom":"universal","scale":"2x"},{"filename":"add@3x.png","idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/add.imageset/add@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/add.imageset/add@2x.png new file mode 100644 index 00000000..d2a29cea Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/add.imageset/add@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/add.imageset/add@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/add.imageset/add@3x.png new file mode 100644 index 00000000..a6ac7858 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/add.imageset/add@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/bg.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/bg.imageset/Contents.json new file mode 100644 index 00000000..e60c1ce6 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/bg.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"idiom":"universal","scale":"1x"},{"filename":"bg@2x.png","idiom":"universal","scale":"2x"},{"filename":"bg@3x.png","idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/bg.imageset/bg@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/bg.imageset/bg@2x.png new file mode 100644 index 00000000..02b213d9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/bg.imageset/bg@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/bg.imageset/bg@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/bg.imageset/bg@3x.png new file mode 100644 index 00000000..63b9de70 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/bg.imageset/bg@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/Contents.json new file mode 100644 index 00000000..56752120 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"idiom":"universal","scale":"1x"},{"filename":"gallery@2x.png","idiom":"universal","scale":"2x"},{"filename":"gallery@3x.png","idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@2x.png new file mode 100644 index 00000000..2ae19f9b Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@3x.png new file mode 100644 index 00000000..d999c0b5 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/microphone.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/microphone.imageset/Contents.json new file mode 100644 index 00000000..ca720697 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/microphone.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"idiom":"universal","scale":"1x"},{"filename":"microphone@2x.png","idiom":"universal","scale":"2x"},{"filename":"microphone@3x.png","idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/microphone.imageset/microphone@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/microphone.imageset/microphone@2x.png new file mode 100644 index 00000000..8a812f22 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/microphone.imageset/microphone@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/microphone.imageset/microphone@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/microphone.imageset/microphone@3x.png new file mode 100644 index 00000000..228fd481 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/microphone.imageset/microphone@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/microphone_thumb.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/microphone_thumb.imageset/Contents.json new file mode 100644 index 00000000..331d4827 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/microphone_thumb.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"idiom":"universal","scale":"1x"},{"filename":"microphone_thumb@2x.png","idiom":"universal","scale":"2x"},{"filename":"microphone_thumb@3x.png","idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/microphone_thumb.imageset/microphone_thumb@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/microphone_thumb.imageset/microphone_thumb@2x.png new file mode 100644 index 00000000..0a10baee Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/microphone_thumb.imageset/microphone_thumb@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/microphone_thumb.imageset/microphone_thumb@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/microphone_thumb.imageset/microphone_thumb@3x.png new file mode 100644 index 00000000..1064289f Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/microphone_thumb.imageset/microphone_thumb@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/pause.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/pause.imageset/Contents.json new file mode 100644 index 00000000..2971bc41 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/pause.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"idiom":"universal","scale":"1x"},{"filename":"pause@2x.png","idiom":"universal","scale":"2x"},{"filename":"pause@3x.png","idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/pause.imageset/pause@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/pause.imageset/pause@2x.png new file mode 100644 index 00000000..46515f81 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/pause.imageset/pause@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/pause.imageset/pause@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/pause.imageset/pause@3x.png new file mode 100644 index 00000000..413e12cf Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/pause.imageset/pause@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/record_microphone.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/record_microphone.imageset/Contents.json new file mode 100644 index 00000000..e1830999 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/record_microphone.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"idiom":"universal","scale":"1x"},{"filename":"record_microphone@2x.png","idiom":"universal","scale":"2x"},{"filename":"record_microphone@3x.png","idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/record_microphone.imageset/record_microphone@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/record_microphone.imageset/record_microphone@2x.png new file mode 100644 index 00000000..8cb991ed Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/record_microphone.imageset/record_microphone@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/record_microphone.imageset/record_microphone@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/record_microphone.imageset/record_microphone@3x.png new file mode 100644 index 00000000..8c0b6422 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/record_microphone.imageset/record_microphone@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/sparkle.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/sparkle.imageset/Contents.json new file mode 100644 index 00000000..b1cbb24e --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/sparkle.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"idiom":"universal","scale":"1x"},{"filename":"sparkle@2x.png","idiom":"universal","scale":"2x"},{"filename":"sparkle@3x.png","idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/sparkle.imageset/sparkle@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/sparkle.imageset/sparkle@2x.png new file mode 100644 index 00000000..4d54ee34 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/sparkle.imageset/sparkle@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/sparkle.imageset/sparkle@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/sparkle.imageset/sparkle@3x.png new file mode 100644 index 00000000..adb4c608 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/sparkle.imageset/sparkle@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/tick_circle.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/tick_circle.imageset/Contents.json new file mode 100644 index 00000000..061b4342 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/tick_circle.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"idiom":"universal","scale":"1x"},{"filename":"tick_circle@2x.png","idiom":"universal","scale":"2x"},{"filename":"tick_circle@3x.png","idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/tick_circle.imageset/tick_circle@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/tick_circle.imageset/tick_circle@2x.png new file mode 100644 index 00000000..dae7f514 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/tick_circle.imageset/tick_circle@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/tick_circle.imageset/tick_circle@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/tick_circle.imageset/tick_circle@3x.png new file mode 100644 index 00000000..12793a93 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/tick_circle.imageset/tick_circle@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/trash.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/trash.imageset/Contents.json new file mode 100644 index 00000000..54cca319 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/trash.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"idiom":"universal","scale":"1x"},{"filename":"trash@2x.png","idiom":"universal","scale":"2x"},{"filename":"trash@3x.png","idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/trash.imageset/trash@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/trash.imageset/trash@2x.png new file mode 100644 index 00000000..0a01a03c Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/trash.imageset/trash@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/trash.imageset/trash@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/trash.imageset/trash@3x.png new file mode 100644 index 00000000..e06564cb Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/trash.imageset/trash@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_alarm_icon.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_alarm_icon.imageset/Contents.json new file mode 100644 index 00000000..f9b57f36 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_alarm_icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_alarm_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_alarm_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_alarm_icon.imageset/sos_alarm_icon@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_alarm_icon.imageset/sos_alarm_icon@2x.png new file mode 100644 index 00000000..67245e57 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_alarm_icon.imageset/sos_alarm_icon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_alarm_icon.imageset/sos_alarm_icon@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_alarm_icon.imageset/sos_alarm_icon@3x.png new file mode 100644 index 00000000..5f9c95f2 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_alarm_icon.imageset/sos_alarm_icon@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_alarm_rays.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_alarm_rays.imageset/Contents.json new file mode 100644 index 00000000..d284f7be --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_alarm_rays.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_alarm_rays@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_alarm_rays@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_alarm_rays.imageset/sos_alarm_rays@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_alarm_rays.imageset/sos_alarm_rays@2x.png new file mode 100644 index 00000000..2a7d7907 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_alarm_rays.imageset/sos_alarm_rays@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_alarm_rays.imageset/sos_alarm_rays@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_alarm_rays.imageset/sos_alarm_rays@3x.png new file mode 100644 index 00000000..021749a9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_alarm_rays.imageset/sos_alarm_rays@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_arrow_right.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_arrow_right.imageset/Contents.json new file mode 100644 index 00000000..a71de129 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_arrow_right.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_arrow_right@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_arrow_right@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_arrow_right.imageset/sos_arrow_right@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_arrow_right.imageset/sos_arrow_right@2x.png new file mode 100644 index 00000000..c834eb5a Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_arrow_right.imageset/sos_arrow_right@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_arrow_right.imageset/sos_arrow_right@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_arrow_right.imageset/sos_arrow_right@3x.png new file mode 100644 index 00000000..07495f8c Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_arrow_right.imageset/sos_arrow_right@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_contact_location.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_contact_location.imageset/Contents.json new file mode 100644 index 00000000..274a4896 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_contact_location.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_contact_location@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_contact_location@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_contact_location.imageset/sos_contact_location@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_contact_location.imageset/sos_contact_location@2x.png new file mode 100644 index 00000000..0efab85e Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_contact_location.imageset/sos_contact_location@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_contact_location.imageset/sos_contact_location@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_contact_location.imageset/sos_contact_location@3x.png new file mode 100644 index 00000000..793e55ab Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_contact_location.imageset/sos_contact_location@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_0.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_countdown_0.imageset/Contents.json new file mode 100644 index 00000000..87b6c31b --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_countdown_0.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_countdown_0@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_countdown_0@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_0.imageset/sos_countdown_0@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_0.imageset/sos_countdown_0@2x.png new file mode 100644 index 00000000..cb4a521c Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_0.imageset/sos_countdown_0@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_0.imageset/sos_countdown_0@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_0.imageset/sos_countdown_0@3x.png new file mode 100644 index 00000000..c0209249 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_0.imageset/sos_countdown_0@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_1.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_countdown_1.imageset/Contents.json new file mode 100644 index 00000000..5a1f6e8f --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_countdown_1.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_countdown_1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_countdown_1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_1.imageset/sos_countdown_1@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_1.imageset/sos_countdown_1@2x.png new file mode 100644 index 00000000..f9538a5a Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_1.imageset/sos_countdown_1@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_1.imageset/sos_countdown_1@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_1.imageset/sos_countdown_1@3x.png new file mode 100644 index 00000000..25de0c01 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_1.imageset/sos_countdown_1@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_2.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_countdown_2.imageset/Contents.json new file mode 100644 index 00000000..dce2fab7 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_countdown_2.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_countdown_2@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_countdown_2@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_2.imageset/sos_countdown_2@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_2.imageset/sos_countdown_2@2x.png new file mode 100644 index 00000000..08e91379 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_2.imageset/sos_countdown_2@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_2.imageset/sos_countdown_2@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_2.imageset/sos_countdown_2@3x.png new file mode 100644 index 00000000..4ec41f76 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_2.imageset/sos_countdown_2@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_3.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_countdown_3.imageset/Contents.json new file mode 100644 index 00000000..cb735759 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_countdown_3.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_countdown_3@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_countdown_3@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_3.imageset/sos_countdown_3@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_3.imageset/sos_countdown_3@2x.png new file mode 100644 index 00000000..046b6a78 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_3.imageset/sos_countdown_3@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_3.imageset/sos_countdown_3@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_3.imageset/sos_countdown_3@3x.png new file mode 100644 index 00000000..4e82943d Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_3.imageset/sos_countdown_3@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_4.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_countdown_4.imageset/Contents.json new file mode 100644 index 00000000..bc1f2c64 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_countdown_4.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_countdown_4@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_countdown_4@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_4.imageset/sos_countdown_4@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_4.imageset/sos_countdown_4@2x.png new file mode 100644 index 00000000..4dca03e0 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_4.imageset/sos_countdown_4@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_4.imageset/sos_countdown_4@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_4.imageset/sos_countdown_4@3x.png new file mode 100644 index 00000000..596aa428 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_4.imageset/sos_countdown_4@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_5.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_countdown_5.imageset/Contents.json new file mode 100644 index 00000000..ac4c7a7f --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_countdown_5.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_countdown_5@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_countdown_5@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_5.imageset/sos_countdown_5@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_5.imageset/sos_countdown_5@2x.png new file mode 100644 index 00000000..b0e4be70 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_5.imageset/sos_countdown_5@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_5.imageset/sos_countdown_5@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_5.imageset/sos_countdown_5@3x.png new file mode 100644 index 00000000..f3de02c9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_5.imageset/sos_countdown_5@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_6.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_countdown_6.imageset/Contents.json new file mode 100644 index 00000000..9dd80c2e --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_countdown_6.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_countdown_6@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_countdown_6@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_6.imageset/sos_countdown_6@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_6.imageset/sos_countdown_6@2x.png new file mode 100644 index 00000000..aa4b3f0f Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_6.imageset/sos_countdown_6@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_6.imageset/sos_countdown_6@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_6.imageset/sos_countdown_6@3x.png new file mode 100644 index 00000000..012b5493 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_6.imageset/sos_countdown_6@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_7.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_countdown_7.imageset/Contents.json new file mode 100644 index 00000000..80fcd1f0 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_countdown_7.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_countdown_7@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_countdown_7@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_7.imageset/sos_countdown_7@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_7.imageset/sos_countdown_7@2x.png new file mode 100644 index 00000000..2fc40dc8 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_7.imageset/sos_countdown_7@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_7.imageset/sos_countdown_7@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_7.imageset/sos_countdown_7@3x.png new file mode 100644 index 00000000..de964071 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_7.imageset/sos_countdown_7@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_8.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_countdown_8.imageset/Contents.json new file mode 100644 index 00000000..f8020d97 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_countdown_8.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_countdown_8@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_countdown_8@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_8.imageset/sos_countdown_8@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_8.imageset/sos_countdown_8@2x.png new file mode 100644 index 00000000..d4026b7f Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_8.imageset/sos_countdown_8@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_8.imageset/sos_countdown_8@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_8.imageset/sos_countdown_8@3x.png new file mode 100644 index 00000000..cdf51834 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_8.imageset/sos_countdown_8@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_9.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_countdown_9.imageset/Contents.json new file mode 100644 index 00000000..7525baa9 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_countdown_9.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_countdown_9@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_countdown_9@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_9.imageset/sos_countdown_9@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_9.imageset/sos_countdown_9@2x.png new file mode 100644 index 00000000..e0267b78 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_9.imageset/sos_countdown_9@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_countdown_9.imageset/sos_countdown_9@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_countdown_9.imageset/sos_countdown_9@3x.png new file mode 100644 index 00000000..ce712457 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_countdown_9.imageset/sos_countdown_9@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/Contents.json new file mode 100644 index 00000000..30431da5 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_guide_3@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_guide_3@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@2x.png new file mode 100644 index 00000000..df757ae1 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@3x.png new file mode 100644 index 00000000..fc4bfe7b Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/Contents.json new file mode 100644 index 00000000..91bf4f2d --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_guide_2@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_guide_2@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@2x.png new file mode 100644 index 00000000..58b46578 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@3x.png new file mode 100644 index 00000000..a876fb1a Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/Contents.json new file mode 100644 index 00000000..831a0201 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_guide_1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_guide_1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@2x.png new file mode 100644 index 00000000..f48b6c86 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@3x.png new file mode 100644 index 00000000..2e0fc613 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_main_button.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_main_button.imageset/Contents.json new file mode 100644 index 00000000..89997cb9 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_main_button.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_main_button@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_main_button@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_main_button.imageset/sos_main_button@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_main_button.imageset/sos_main_button@2x.png new file mode 100644 index 00000000..13d6c891 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_main_button.imageset/sos_main_button@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_main_button.imageset/sos_main_button@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_main_button.imageset/sos_main_button@3x.png new file mode 100644 index 00000000..85c281ad Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_main_button.imageset/sos_main_button@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_nav_more.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_nav_more.imageset/Contents.json new file mode 100644 index 00000000..f896a917 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_nav_more.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_nav_more@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_nav_more@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_nav_more.imageset/sos_nav_more@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_nav_more.imageset/sos_nav_more@2x.png new file mode 100644 index 00000000..495602b3 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_nav_more.imageset/sos_nav_more@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_nav_more.imageset/sos_nav_more@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_nav_more.imageset/sos_nav_more@3x.png new file mode 100644 index 00000000..1d842de7 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_nav_more.imageset/sos_nav_more@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/Contents.json new file mode 100644 index 00000000..bfb1fa2e --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_phone_badge@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_phone_badge@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@2x.png new file mode 100644 index 00000000..219c352f Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@3x.png new file mode 100644 index 00000000..f9df9522 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/Contents.json new file mode 100644 index 00000000..58e7f82b --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_practice_success@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_practice_success@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@2x.png new file mode 100644 index 00000000..ee66bfc0 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@3x.png new file mode 100644 index 00000000..5badcffb Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_receive_alarm.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_receive_alarm.imageset/Contents.json new file mode 100644 index 00000000..17fe2bc0 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_receive_alarm.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_receive_alarm@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_receive_alarm@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_receive_alarm.imageset/sos_receive_alarm@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_receive_alarm.imageset/sos_receive_alarm@2x.png new file mode 100644 index 00000000..b2d03310 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_receive_alarm.imageset/sos_receive_alarm@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_receive_alarm.imageset/sos_receive_alarm@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_receive_alarm.imageset/sos_receive_alarm@3x.png new file mode 100644 index 00000000..c49f05a3 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_receive_alarm.imageset/sos_receive_alarm@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_receive_title.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_receive_title.imageset/Contents.json new file mode 100644 index 00000000..021c66d2 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_receive_title.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_receive_title@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_receive_title@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_receive_title.imageset/sos_receive_title@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_receive_title.imageset/sos_receive_title@2x.png new file mode 100644 index 00000000..826780d8 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_receive_title.imageset/sos_receive_title@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_receive_title.imageset/sos_receive_title@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_receive_title.imageset/sos_receive_title@3x.png new file mode 100644 index 00000000..4207ed98 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_receive_title.imageset/sos_receive_title@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_slider_arrow.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_slider_arrow.imageset/Contents.json new file mode 100644 index 00000000..41b36ddb --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_slider_arrow.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_slider_arrow@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_slider_arrow@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_slider_arrow.imageset/sos_slider_arrow@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_slider_arrow.imageset/sos_slider_arrow@2x.png new file mode 100644 index 00000000..9739a1a3 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_slider_arrow.imageset/sos_slider_arrow@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_slider_arrow.imageset/sos_slider_arrow@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_slider_arrow.imageset/sos_slider_arrow@3x.png new file mode 100644 index 00000000..6d99a681 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_slider_arrow.imageset/sos_slider_arrow@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_tap_hand.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_tap_hand.imageset/Contents.json new file mode 100644 index 00000000..9507c211 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_tap_hand.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_tap_hand@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_tap_hand@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_tap_hand.imageset/sos_tap_hand@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_tap_hand.imageset/sos_tap_hand@2x.png new file mode 100644 index 00000000..28655044 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_tap_hand.imageset/sos_tap_hand@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_tap_hand.imageset/sos_tap_hand@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_tap_hand.imageset/sos_tap_hand@3x.png new file mode 100644 index 00000000..a95d147f Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_tap_hand.imageset/sos_tap_hand@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_title.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_title.imageset/Contents.json new file mode 100644 index 00000000..dc29bc32 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_title.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_title@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_title@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_title.imageset/sos_title@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_title.imageset/sos_title@2x.png new file mode 100644 index 00000000..5fcdcb30 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_title.imageset/sos_title@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_title.imageset/sos_title@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_title.imageset/sos_title@3x.png new file mode 100644 index 00000000..e741ccd1 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_title.imageset/sos_title@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/add.imageset/Group_2267@2x.png b/QuickLocation/Assets.xcassets/Schedule/add.imageset/Group_2267@2x.png index 29956fed..d88f6069 100644 Binary files a/QuickLocation/Assets.xcassets/Schedule/add.imageset/Group_2267@2x.png and b/QuickLocation/Assets.xcassets/Schedule/add.imageset/Group_2267@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/add.imageset/Group_2267@3x.png b/QuickLocation/Assets.xcassets/Schedule/add.imageset/Group_2267@3x.png index 9e338006..dbe14879 100644 Binary files a/QuickLocation/Assets.xcassets/Schedule/add.imageset/Group_2267@3x.png and b/QuickLocation/Assets.xcassets/Schedule/add.imageset/Group_2267@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/clock.imageset/Contents.json b/QuickLocation/Assets.xcassets/Schedule/clock.imageset/Contents.json new file mode 100644 index 00000000..4231c99e --- /dev/null +++ b/QuickLocation/Assets.xcassets/Schedule/clock.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "clock@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "clock@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Schedule/clock.imageset/clock@2x.png b/QuickLocation/Assets.xcassets/Schedule/clock.imageset/clock@2x.png new file mode 100644 index 00000000..21faf2cd Binary files /dev/null and b/QuickLocation/Assets.xcassets/Schedule/clock.imageset/clock@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/clock.imageset/clock@3x.png b/QuickLocation/Assets.xcassets/Schedule/clock.imageset/clock@3x.png new file mode 100644 index 00000000..c1161db6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Schedule/clock.imageset/clock@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/delete.imageset/Group_2308@2x.png b/QuickLocation/Assets.xcassets/Schedule/delete.imageset/Group_2308@2x.png index bb75dbcf..bec0a64e 100644 Binary files a/QuickLocation/Assets.xcassets/Schedule/delete.imageset/Group_2308@2x.png and b/QuickLocation/Assets.xcassets/Schedule/delete.imageset/Group_2308@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/delete.imageset/Group_2308@3x.png b/QuickLocation/Assets.xcassets/Schedule/delete.imageset/Group_2308@3x.png index 465766d4..afda884a 100644 Binary files a/QuickLocation/Assets.xcassets/Schedule/delete.imageset/Group_2308@3x.png and b/QuickLocation/Assets.xcassets/Schedule/delete.imageset/Group_2308@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/drag.imageset/Contents.json b/QuickLocation/Assets.xcassets/Schedule/drag.imageset/Contents.json new file mode 100644 index 00000000..cf242dfa --- /dev/null +++ b/QuickLocation/Assets.xcassets/Schedule/drag.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "drag@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "drag@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Schedule/drag.imageset/drag@2x.png b/QuickLocation/Assets.xcassets/Schedule/drag.imageset/drag@2x.png new file mode 100644 index 00000000..0a87cff1 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Schedule/drag.imageset/drag@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/drag.imageset/drag@3x.png b/QuickLocation/Assets.xcassets/Schedule/drag.imageset/drag@3x.png new file mode 100644 index 00000000..6d8924de Binary files /dev/null and b/QuickLocation/Assets.xcassets/Schedule/drag.imageset/drag@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/edit.imageset/Contents.json b/QuickLocation/Assets.xcassets/Schedule/edit.imageset/Contents.json new file mode 100644 index 00000000..b1c8a4fc --- /dev/null +++ b/QuickLocation/Assets.xcassets/Schedule/edit.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "edit@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "edit@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Schedule/edit.imageset/edit@2x.png b/QuickLocation/Assets.xcassets/Schedule/edit.imageset/edit@2x.png new file mode 100644 index 00000000..486125a5 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Schedule/edit.imageset/edit@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/edit.imageset/edit@3x.png b/QuickLocation/Assets.xcassets/Schedule/edit.imageset/edit@3x.png new file mode 100644 index 00000000..4cc2b38a Binary files /dev/null and b/QuickLocation/Assets.xcassets/Schedule/edit.imageset/edit@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/location.imageset/Group_1584@2x.png b/QuickLocation/Assets.xcassets/Schedule/location.imageset/Group_1584@2x.png index 3ee231fa..99b4b5d0 100644 Binary files a/QuickLocation/Assets.xcassets/Schedule/location.imageset/Group_1584@2x.png and b/QuickLocation/Assets.xcassets/Schedule/location.imageset/Group_1584@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/location.imageset/Group_1584@3x.png b/QuickLocation/Assets.xcassets/Schedule/location.imageset/Group_1584@3x.png index 2e502110..9ac0fcd2 100644 Binary files a/QuickLocation/Assets.xcassets/Schedule/location.imageset/Group_1584@3x.png and b/QuickLocation/Assets.xcassets/Schedule/location.imageset/Group_1584@3x.png differ diff --git a/QuickLocation/Manager/App/RouterManager.swift b/QuickLocation/Manager/App/RouterManager.swift index cf3d07be..501db0fe 100644 --- a/QuickLocation/Manager/App/RouterManager.swift +++ b/QuickLocation/Manager/App/RouterManager.swift @@ -89,6 +89,8 @@ enum Route: String { case appRestrict = "appRestrict" /// 锁住分心(一键锁机) case lockDistract = "lockDistract" + /// 飞鸽传书 + case pigeonMessage = "pigeonMessage" /// 还在吗 / 打卡 case signIn = "signIn" /// SOS @@ -404,6 +406,13 @@ extension AppRouter: AppRouterProtocol { return vc } + // MARK: - 飞鸽传书 + AppRouter.register(Route.pigeonMessage) { _, _ in + let vc = PigeonMessageVC() + vc.isNeedLogin = true + return vc + } + // MARK: - 还在吗 AppRouter.register(Route.signIn) { _, _ in SignInVC(lastLocation: nil) diff --git a/QuickLocation/Section/Explore/FeatureIntroVC.swift b/QuickLocation/Section/Explore/FeatureIntroVC.swift index e891c8a3..3ee9083e 100644 --- a/QuickLocation/Section/Explore/FeatureIntroVC.swift +++ b/QuickLocation/Section/Explore/FeatureIntroVC.swift @@ -35,6 +35,8 @@ final class FeatureIntroVC: BaseViewController { AppRouter.push(Route.createBubble) case .lockDistract: AppRouter.push(Route.lockDistract) + case .pigeonMessage: + AppRouter.push(Route.pigeonMessage) case .searchLocation: AppRouter.push(Route.searchLocation) case .sos: diff --git a/QuickLocation/Section/Explore/FeatureIntroView.swift b/QuickLocation/Section/Explore/FeatureIntroView.swift index ed885ae6..91ee90ee 100644 --- a/QuickLocation/Section/Explore/FeatureIntroView.swift +++ b/QuickLocation/Section/Explore/FeatureIntroView.swift @@ -19,6 +19,7 @@ enum FeatureIntroAction { case signIn case createBubble case lockDistract + case pigeonMessage case searchLocation case sos case placeholder @@ -68,7 +69,7 @@ final class FeatureIntroView: UIView { titleColor: UIColor(hexStr: "#A67C2D"), subtitleColor: UIColor(hexStr: "#C4A46A"), arrowTint: UIColor(hexStr: "#C9953A"), - action: .placeholder + action: .pigeonMessage ), FeatureIntroItem( title: "查位置", diff --git a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift index b03ed8b3..8c62c9d9 100644 --- a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift +++ b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift @@ -652,14 +652,14 @@ class GroupMemberListCell: UICollectionViewCell { private static let sosBlinkKey = "sosBlink" - func configure(model: GroupMemberModel, isCurrentUser: Bool, isSelected: Bool, isSOSBlinking: Bool = false) { + func configure(model: GroupMemberModel, isCurrentUser: Bool, isSelected: Bool) { avaterImgView.image = model.userIcon // vipIcon.image = model.vipIcon vipBgImgView.isHidden = model.level == 1 nameLab.text = model.nick_name nameLab.textColor = UIColor(hexStr: isCurrentUser ? "#16B3FF" : "#0F2846") selectedBgView.isHidden = !isSelected - updateSOSBlink(isSOSBlinking) + updateSOSBlink(model.status == 1) // 会员权益 // if AppContextManager.shared.vip > 1, model.is_online { // batteryInfoView.isHidden = model.battery.int == 0 diff --git a/QuickLocation/Section/Home/HomeView2.swift b/QuickLocation/Section/Home/HomeView2.swift index 631d66dd..a297775b 100644 --- a/QuickLocation/Section/Home/HomeView2.swift +++ b/QuickLocation/Section/Home/HomeView2.swift @@ -14,6 +14,8 @@ import MarqueeLabel class HomeView2: UIView { var disposeBag = DisposeBag() + var onUnlockRequestTap: (() -> Void)? + var onUnlockReceiveTap: (() -> Void)? let groupMemberView = GroupMemberView2(frame: .zero) @@ -56,19 +58,37 @@ class HomeView2: UIView { warningView.rx.tapGesture.subscribe { _ in LocationPermissionPopView.show() }.disposed(by: disposeBag) + + unlockRequestBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.onUnlockRequestTap?() + }) + .disposed(by: disposeBag) + + unlockReceiveBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.onUnlockReceiveTap?() + }) + .disposed(by: disposeBag) } private func setupUI() { addSubview(mapView) + addSubview(sosGradientView) addSubview(gpsSignalView) addSubview(warningView) addSubview(inviteBtn) addSubview(locationView) + addSubview(actionStackView) addSubview(groupMemberView) mapView.layoutChain .edges(excludingEdge: .bottom) .bottom(49 + kSafeBottomMargin) + + sosGradientView.layoutChain + .edges(excludingEdge: .bottom) + .bottom(49 + kSafeBottomMargin) gpsSignalView.layoutChain .top(kStatusBarHeight + 11) @@ -93,6 +113,10 @@ class HomeView2: UIView { .left(15) .width(32) .heightToWidth(1) + + actionStackView.layoutChain + .right(14) + .bottomToTopOfView(inviteBtn, offset: -20) groupMemberTopConstraint = groupMemberView.jh_constraint( .top, toAttribute: .top, otherView: self, relation: .equal @@ -118,6 +142,12 @@ class HomeView2: UIView { DispatchQueue.main.async { mv.logoCenter = CGPoint(x: kScreenWidth - 50, y: kStatusBarHeight) } return mv }() + + private let sosGradientView = HomeSOSGradientView() + + func setSOSGradientActive(_ isActive: Bool) { + sosGradientView.setActive(isActive) + } // MARK: - 卫星信号 lazy var gpsSignalView: UIView = { @@ -203,6 +233,38 @@ class HomeView2: UIView { return view }() + + // MARK: - 解锁操作 + lazy var actionStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [unlockRequestBtn, unlockReceiveBtn]) + stackView.axis = .vertical + stackView.alignment = .center + stackView.distribution = .fill + stackView.spacing = 8 + + unlockRequestBtn.layoutChain + .width(51) + .height(50) + unlockReceiveBtn.layoutChain + .width(50) + .height(50) + + return stackView + }() + + lazy var unlockRequestBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "Home/unlock_request")?.withRenderingMode(.alwaysOriginal), for: .normal) + btn.imageView?.contentMode = .scaleAspectFit + return btn + }() + + lazy var unlockReceiveBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "Home/unlock_receive")?.withRenderingMode(.alwaysOriginal), for: .normal) + btn.imageView?.contentMode = .scaleAspectFit + return btn + }() // MARK: - 定位按钮 lazy var locationView: UIView = { @@ -358,6 +420,65 @@ class HomeView2: UIView { } } +private final class HomeSOSGradientView: UIView { + + private let gradientLayer = CAGradientLayer() + private static let flashAnimationKey = "home.sos.gradient.flash" + + override init(frame: CGRect) { + super.init(frame: frame) + isHidden = true + isUserInteractionEnabled = false + backgroundColor = .clear + + gradientLayer.type = .radial + gradientLayer.colors = [ + UIColor.clear.cgColor, + UIColor(hexStr: "#FF7378").withAlphaComponent(0.16).cgColor, + UIColor(hexStr: "#FF7378").withAlphaComponent(0.58).cgColor + ] + gradientLayer.locations = [0, 0.58, 1] + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.48) + gradientLayer.endPoint = CGPoint(x: 1, y: 1) + layer.addSublayer(gradientLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } + + func setActive(_ isActive: Bool) { + guard isActive != !isHidden else { return } + + if isActive { + isHidden = false + startFlashing() + } else { + gradientLayer.removeAnimation(forKey: Self.flashAnimationKey) + gradientLayer.opacity = 1 + isHidden = true + } + } + + private func startFlashing() { + guard !UIAccessibility.isReduceMotionEnabled else { return } + + let animation = CAKeyframeAnimation(keyPath: "opacity") + animation.values = [0.38, 1.0, 0.38] + animation.keyTimes = [0, 0.5, 1] + animation.duration = 1.15 + animation.repeatCount = .infinity + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animation.isRemovedOnCompletion = false + gradientLayer.add(animation, forKey: Self.flashAnimationKey) + } +} + // MARK: - UIScrollViewDelegate extension HomeView2: UIScrollViewDelegate { func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index f8f99df4..f1a89e9b 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -11,6 +11,7 @@ import RxCocoa import RxDataSources import CoreLocation import AVFoundation +import AudioToolbox import SwiftyUserDefaults #if !targetEnvironment(simulator) @@ -50,15 +51,19 @@ class HomeViewController: BaseViewController { /// 底部成员面板是否显示 private var isMemberPanelShown = false private var isSOSPlaying = false - /// SOS 闪烁截止时间(userId → Date) - private var sosBlinkUntil: [String: Date] = [:] - /// SOS 闪烁定时任务(userId → WorkItem) - private var sosBlinkWorkItems: [String: DispatchWorkItem] = [:] + private var sosReceiveAlertView: SOSReceiveAlertView? + private var sosAudioStopWorkItem: DispatchWorkItem? + private var sosVibrationTimer: DispatchSourceTimer? /// 记录每个成员最后一次收到位置的时间(MQTT userId → Date) private var lastUpdateTimes: [String: Date] = [:] /// 离线检测定时器 private var offlineCheckTimer: Timer? private var popupQueue: PopupQueueManager? + private lazy var mockUnlockStartTimes: [Date] = [ + Date().addingTimeInterval(-(11 * 3600 + 11 * 60)), + Date().addingTimeInterval(-(3 * 3600 + 28 * 60)), + Date().addingTimeInterval(-(25 * 60)) + ] private static var sosPlayerKey: UInt8 = 0 private var groupRefreshWorkItem: DispatchWorkItem? @@ -147,6 +152,27 @@ class HomeViewController: BaseViewController { // MARK: - Actions private func reactiveAction() { + rootView.onUnlockRequestTap = { [weak self] in + guard let self else { return } + let requests = self.makeMockUnlockRequests() + UnlockRequestPopView.show( + requests: requests, + onUnlock: { _ in + // 预留解锁回调,后续接入 AppRestrict / API。 + }, + onReject: { _ in + // 预留拒绝回调,后续接入 API。 + } + ) + } + + rootView.onUnlockReceiveTap = { [weak self] in + guard let self else { return } + ReceiveMessagePopView.show(items: self.makeMockReceiveMessages()) { _ in + // 预留阅后即焚完成回调,后续接入消息销毁接口。 + } + } + // 气泡 // rootView.bubbleView.rx.tapGesture.subscribe { _ in // AppRouter.push(Route.createBubble) @@ -314,7 +340,10 @@ class HomeViewController: BaseViewController { .do(onNext: { [weak self] sections in guard let self = self else { return } let items = sections.flatMap(\.items) - guard !items.isEmpty else { return } + guard !items.isEmpty else { + self.rootView.setSOSGradientActive(false) + return + } let stillValid = items.contains { $0.user_id == self.selectedMemberId } if self.selectedMemberId.isEmpty || !stillValid { if let me = items.first(where: { self.viewModel.isCurrentUser(id: $0.user_id) }) { @@ -331,6 +360,7 @@ class HomeViewController: BaseViewController { ) } } + self.updateSelectedMemberSOSGradient() }) .bind(to: memberCV.rx.items(dataSource: dataSource)) .disposed(by: disposeBag) @@ -354,11 +384,9 @@ class HomeViewController: BaseViewController { lazy private var dataSource: RxCollectionViewSectionedReloadDataSource = { RxCollectionViewSectionedReloadDataSource { [weak self] _, collectionView, indexPath, model in let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath) - let blinking = self?.isSOSBlinking(userId: model.user_id) ?? false cell.configure(model: model, isCurrentUser: self?.viewModel.isCurrentUser(id: model.user_id) ?? false, - isSelected: model.user_id == self?.selectedMemberId, - isSOSBlinking: blinking) + isSelected: model.user_id == self?.selectedMemberId) return cell } }() @@ -377,6 +405,67 @@ class HomeViewController: BaseViewController { }).disposed(by: disposeBag) } + private func makeMockUnlockRequests() -> [UnlockRequestDisplayItem] { + var candidates = viewModel.memberList + guard !candidates.isEmpty else { return [] } + + if let selectedIndex = candidates.firstIndex(where: { $0.user_id == selectedMemberId }) { + let selectedMember = candidates.remove(at: selectedIndex) + candidates.insert(selectedMember, at: 0) + } + + while candidates.count < 3 { + candidates.append(contentsOf: candidates) + } + + return Array(candidates.prefix(3)).enumerated().map { index, member in + UnlockRequestDisplayItem( + member: member, + lockStartTime: mockUnlockStartTimes[index] + ) + } + } + + private func makeMockReceiveMessages() -> [ReceiveMessageDisplayItem] { + var candidates = viewModel.memberList + let fallbackImage = UIImage(named: "UserIcon/1") + let mockMessages = [ + "想对你说的话都藏在这里,请查收呀", + "今天也要开开心心,注意安全哦" + ] + + guard !candidates.isEmpty else { + return [ + ReceiveMessageDisplayItem( + senderName: "圈子成员", + avatar: fallbackImage, + image: fallbackImage, + message: mockMessages[0] + ) + ] + } + + if let selectedIndex = candidates.firstIndex(where: { $0.user_id == selectedMemberId }) { + let selectedMember = candidates.remove(at: selectedIndex) + candidates.insert(selectedMember, at: 0) + } + + while candidates.count < 2 { + candidates.append(contentsOf: candidates) + } + + return Array(candidates.prefix(2)).enumerated().map { index, member in + let memberImage = member.userIcon + let displayImage = memberImage.size == .zero ? fallbackImage : memberImage + return ReceiveMessageDisplayItem( + senderName: member.nick_name, + avatar: displayImage, + image: displayImage, + message: mockMessages[index] + ) + } + } + private func requestUserInfo(completion: (() -> Void)? = nil) { UserService.userInfo().subscribe { response in guard let model = response.model else { return } @@ -575,6 +664,11 @@ class HomeViewController: BaseViewController { ) } + private func updateSelectedMemberSOSGradient() { + let isActive = viewModel.memberList.first(where: { $0.user_id == selectedMemberId })?.status == 1 + rootView.setSOSGradientActive(isActive) + } + private func openTodayTrackDetail() { let members = viewModel.memberList.map { $0.toJSON() } let userInfo: [String: Any] = [ @@ -602,6 +696,7 @@ class HomeViewController: BaseViewController { guard !listUserId.isEmpty else { return } selectedMemberId = listUserId + updateSelectedMemberSOSGradient() refreshSelectedMemberInfoIfNeeded(userId: listUserId) memberCV.reloadData() @@ -805,22 +900,8 @@ extension HomeViewController { DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.viewModel.updateMemberStatus(userId: userId, status: 1) - self.startSOSBlink(userId: userId) -// self.selectMember(userId: userId, centerMap: true, dismissPanel: false) - - guard !self.isSOSPlaying else { return } - self.isSOSPlaying = true - self.playSOSAlarm() -// self.rootView.sosPopView.isHidden = false -// self.rootView.sosPopView.play { completed in -// self.rootView.sosPopView.stop() -// self.rootView.sosPopView.isHidden = true -// } - DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in - self?.sosPlayer?.stop() - self?.sosPlayer = nil - self?.isSOSPlaying = false - } + guard let member = self.viewModel.memberList.first(where: { $0.user_id == userId }) else { return } + self.showSOSReceiveAlert(for: member) } case "join": // 圈子有成员加入 guard let gk = msg.data?.group_key, @@ -1003,7 +1084,7 @@ extension HomeViewController: MAMapViewDelegate { } } -// MARK: - SOS 报警音 +// MARK: - SOS 接收提示和报警音 extension HomeViewController { private var sosPlayer: AVAudioPlayer? { @@ -1011,29 +1092,35 @@ extension HomeViewController { set { objc_setAssociatedObject(self, &HomeViewController.sosPlayerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - private func isSOSBlinking(userId: String) -> Bool { - guard let until = sosBlinkUntil[userId] else { return false } - return until > Date() - } - - /// 启动 / 重置该成员 15 秒 SOS 头像闪烁 - private func startSOSBlink(userId: String) { - sosBlinkWorkItems[userId]?.cancel() - sosBlinkUntil[userId] = Date().addingTimeInterval(15) - memberCV.reloadData() - - let work = DispatchWorkItem { [weak self] in - guard let self = self else { return } - self.sosBlinkUntil.removeValue(forKey: userId) - self.sosBlinkWorkItems.removeValue(forKey: userId) - self.memberCV.reloadData() + private func showSOSReceiveAlert(for member: GroupMemberModel) { + if let alertView = sosReceiveAlertView, alertView.superview != nil { + alertView.appendSecondary(member) + playSOSAlarmIfNeeded() + return } - sosBlinkWorkItems[userId] = work - DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: work) + + guard let window = UIApplication.keyWindow else { return } + let alertView = SOSReceiveAlertView(frame: window.bounds) + alertView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + alertView.onAcknowledged = { [weak self, weak alertView] in + guard let self else { return } + alertView?.dismiss() + if self.sosReceiveAlertView === alertView { + self.sosReceiveAlertView = nil + } + self.stopSOSAlarm() + } + window.addSubview(alertView) + sosReceiveAlertView = alertView + alertView.show(primary: member) + playSOSAlarmIfNeeded() } - private func playSOSAlarm() { + private func playSOSAlarmIfNeeded() { + guard !isSOSPlaying else { return } + // 停止上一次报警 + sosAudioStopWorkItem?.cancel() sosPlayer?.stop() sosPlayer = nil @@ -1056,6 +1143,43 @@ extension HomeViewController { player.volume = 1.0 player.play() self.sosPlayer = player + isSOSPlaying = true + startSOSVibration() + + let stopWork = DispatchWorkItem { [weak self] in + self?.stopSOSAlarm() + } + sosAudioStopWorkItem = stopWork + DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: stopWork) + } + + private func stopSOSAlarm() { + sosAudioStopWorkItem?.cancel() + sosAudioStopWorkItem = nil + stopSOSVibration() + sosPlayer?.stop() + sosPlayer = nil + isSOSPlaying = false + } + + private func startSOSVibration() { + stopSOSVibration() + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + 1, repeating: 1) + timer.setEventHandler { [weak self] in + guard self?.isSOSPlaying == true else { return } + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + } + sosVibrationTimer = timer + timer.resume() + } + + private func stopSOSVibration() { + sosVibrationTimer?.setEventHandler {} + sosVibrationTimer?.cancel() + sosVibrationTimer = nil } } diff --git a/QuickLocation/Section/Home/ReceiveMessagePopView.swift b/QuickLocation/Section/Home/ReceiveMessagePopView.swift new file mode 100644 index 00000000..409359db --- /dev/null +++ b/QuickLocation/Section/Home/ReceiveMessagePopView.swift @@ -0,0 +1,636 @@ +// +// ReceiveMessagePopView.swift +// QuickLocation +// + +import UIKit + +struct ReceiveMessageDisplayItem { + let senderName: String + let avatar: UIImage? + let image: UIImage? + let message: String +} + +final class ReceiveMessagePopView: UIView { + + private static let shared = ReceiveMessagePopView( + frame: CGRect(origin: .zero, size: kScreenSize) + ) + + private var messages: [ReceiveMessageDisplayItem] = [] + private var item: ReceiveMessageDisplayItem? + private var onBurn: ((ReceiveMessageDisplayItem) -> Void)? + private var isBurning = false + private var effectLayers: [CALayer] = [] + private var burnDisplayLink: CADisplayLink? + private var burnStartTime: CFTimeInterval = 0 + private let burnDuration: CFTimeInterval = 1.05 + private var burnLetterFrame = CGRect.zero + private var burnMaskLayer: CAShapeLayer? + private var charEdgeLayer: CAShapeLayer? + private var fireEdgeLayer: CAShapeLayer? + private var hotEdgeLayer: CAShapeLayer? + private var burnEmitterLayer: CAEmitterLayer? + + static func show( + item: ReceiveMessageDisplayItem, + onBurn: ((ReceiveMessageDisplayItem) -> Void)? = nil + ) { + show(items: [item], onBurn: onBurn) + } + + static func show( + items: [ReceiveMessageDisplayItem], + onBurn: ((ReceiveMessageDisplayItem) -> Void)? = nil + ) { + guard let firstItem = items.first, let window = kKeyWindow else { return } + + let popup = ReceiveMessagePopView.shared + popup.removeFromSuperview() + popup.resetBurnState() + popup.messages = items + popup.item = firstItem + popup.onBurn = onBurn + popup.configure(item: firstItem) + + window.addSubview(popup) + window.bringSubviewToFront(popup) + popup.layoutIfNeeded() + + popup.overlayView.alpha = 0 + popup.letterView.alpha = 0 + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut]) { + popup.overlayView.alpha = 1 + } + popup.animateInitialFlyIn() + } + + private let overlayView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.62) + return view + }() + + private let letterView = UIView() + + private let cardBackgroundView: UIImageView = { + let view = UIImageView(image: UIImage(named: "Home/receive_message_card")) + view.contentMode = .scaleToFill + return view + }() + + private let titleImageView: UIImageView = { + let view = UIImageView(image: UIImage(named: "Home/receive_message_title")) + view.contentMode = .scaleAspectFit + return view + }() + + private let avatarView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.layer.cornerRadius = 16 + view.layer.borderColor = UIColor.white.cgColor + view.layer.borderWidth = 4 + return view + }() + + private let nameLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .semibold) + label.textColor = UIColor(hexStr: "#293445") + label.textAlignment = .center + return label + }() + + private let relationLabel: UILabel = { + let label = UILabel() + label.text = "💕" + label.font = .systemFont(ofSize: 15) + return label + }() + + private let messageImageView: UIImageView = { + let view = UIImageView() + view.backgroundColor = UIColor(hexStr: "#D9D9D9") + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.layer.cornerRadius = 36 + return view + }() + + private let messageLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .medium) + label.textColor = UIColor(hexStr: "#293445") + label.textAlignment = .center + label.numberOfLines = 0 + label.lineBreakMode = .byTruncatingTail + return label + }() + + private let burnButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("阅后即焚", for: .normal) + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = FontManager.boboBold(18) + button.backgroundColor = UIColor(hexStr: "#293445") + button.layer.cornerRadius = 20 + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + addSubview(overlayView) + addSubview(letterView) + overlayView.layoutChain.edges() + + letterView.layoutChain + .edgesHorzontal(26) + .centerY() + + letterView.addSubview(cardBackgroundView) + letterView.addSubview(titleImageView) + letterView.addSubview(avatarView) + letterView.addSubview(nameLabel) + + letterView.addSubview(messageImageView) + letterView.addSubview(messageLabel) + letterView.addSubview(burnButton) + + cardBackgroundView.layoutChain + .top(38) + .edgesHorzontal() + .heightToWidth(480/323) + .bottom() + + titleImageView.layoutChain + .top() + .left(18) + .width(180) + .height(56) + + avatarView.layoutChain + .topToBottomOfView(titleImageView, offset: 5) + .centerX() + .width(40) + .height(40) + + nameLabel.layoutChain + .topToBottomOfView(avatarView, offset: 5) + .centerX() + + messageImageView.layoutChain + .topToBottomOfView(nameLabel, offset: 16) + .centerX() + .width(200) + .height(224) + + messageLabel.layoutChain + .topToBottomOfView(messageImageView, offset: 13) + .edgesHorzontal(30) + + burnButton.layoutChain + .bottom(25) + .edgesHorzontal(24) + .height(56) + + burnButton.addTarget(self, action: #selector(tapBurn), for: .touchUpInside) + } + + private func configure(item: ReceiveMessageDisplayItem) { + nameLabel.text = item.senderName.isEmpty ? "圈子成员" : item.senderName + avatarView.image = item.avatar ?? UIImage(named: "UserIcon/1") + messageImageView.image = item.image + messageLabel.text = String(item.message.prefix(20)) + } + + private func animateInitialFlyIn() { + burnButton.isUserInteractionEnabled = false + letterView.alpha = 1 + letterView.transform = .identity + letterView.layer.transform = CATransform3DIdentity + letterView.layer.shadowColor = UIColor.black.cgColor + letterView.layer.shadowRadius = 18 + letterView.layer.shadowOffset = CGSize(width: 0, height: 12) + letterView.layer.shadowOpacity = 0 + letterView.layer.shouldRasterize = true + letterView.layer.rasterizationScale = UIScreen.main.scale + + func flightTransform( + x: CGFloat, + y: CGFloat, + z: CGFloat, + scale: CGFloat, + rotateZ: CGFloat, + rotateY: CGFloat + ) -> CATransform3D { + var transform = CATransform3DIdentity + transform.m34 = -1 / 800 + transform = CATransform3DTranslate(transform, x, y, z) + transform = CATransform3DRotate(transform, rotateY, 0, 1, 0) + transform = CATransform3DRotate(transform, rotateZ, 0, 0, 1) + return CATransform3DScale(transform, scale, scale, 1) + } + + let rotationSteps = 16 + let startX = min(bounds.width * 0.75, 280) + let startY = min(bounds.height * 0.48, 370) + let transformValues: [NSValue] = (0...rotationSteps).map { index in + guard index < rotationSteps else { + return NSValue(caTransform3D: CATransform3DIdentity) + } + + let progress = CGFloat(index) / CGFloat(rotationSteps) + let remaining = 1 - progress + let distanceProgress = remaining * remaining + var x = -startX * distanceProgress + var y = -startY * distanceProgress + var z = -650 * distanceProgress + var scale = 0.88 + 0.12 * (1 - distanceProgress) + + if index == rotationSteps - 1 { + x = -5 + y = 4 + z = 28 + scale = 1.018 + } + + return NSValue( + caTransform3D: flightTransform( + x: x, + y: y, + z: z, + scale: scale, + rotateZ: -.pi * 8 * remaining, + rotateY: -.pi * 0.12 * remaining + ) + ) + } + let transformKeyTimes: [NSNumber] = (0...rotationSteps).map { + NSNumber(value: Double($0) / Double(rotationSteps)) + } + + let transformAnimation = CAKeyframeAnimation(keyPath: "transform") + transformAnimation.values = transformValues + transformAnimation.keyTimes = transformKeyTimes + transformAnimation.timingFunctions = Array( + repeating: CAMediaTimingFunction(name: .linear), + count: rotationSteps + ) + + let opacityAnimation = CAKeyframeAnimation(keyPath: "opacity") + opacityAnimation.values = [0.35, 0.6, 0.82, 1, 1, 1] + opacityAnimation.keyTimes = [0, 0.22, 0.45, 0.68, 0.86, 1] + + let shadowAnimation = CAKeyframeAnimation(keyPath: "shadowOpacity") + shadowAnimation.values = [0.02, 0.04, 0.1, 0.18, 0.3, 0] + shadowAnimation.keyTimes = [0, 0.22, 0.45, 0.68, 0.86, 1] + + let group = CAAnimationGroup() + group.animations = [transformAnimation, opacityAnimation, shadowAnimation] + group.duration = 0.72 + group.isRemovedOnCompletion = true + letterView.layer.add(group, forKey: "initialFlight") + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.74) { [weak self] in + guard let self, !self.isBurning else { return } + self.letterView.layer.shouldRasterize = false + self.letterView.layer.shadowOpacity = 0 + self.letterView.layer.shadowRadius = 0 + self.letterView.layer.shadowOffset = .zero + self.burnButton.isUserInteractionEnabled = true + } + } + + @objc private func tapBurn() { + guard !isBurning else { return } + isBurning = true + burnButton.isUserInteractionEnabled = false + playBurnAnimation() + } + + private func playBurnAnimation() { + layoutIfNeeded() + burnLetterFrame = letterView.convert(letterView.bounds, to: self) + + let maskLayer = CAShapeLayer() + maskLayer.frame = letterView.bounds + maskLayer.fillColor = UIColor.white.cgColor + letterView.layer.mask = maskLayer + burnMaskLayer = maskLayer + + addBurningEdges() + addBurnParticles() + + burnStartTime = CACurrentMediaTime() + updateBurn(progress: 0, timestamp: burnStartTime) + + let displayLink = CADisplayLink(target: self, selector: #selector(updateBurnFrame(_:))) + displayLink.add(to: .main, forMode: .common) + burnDisplayLink = displayLink + } + + @objc private func updateBurnFrame(_ displayLink: CADisplayLink) { + let elapsed = max(0, displayLink.timestamp - burnStartTime) + let progress = min(1, elapsed / burnDuration) + updateBurn(progress: CGFloat(progress), timestamp: displayLink.timestamp) + + guard progress >= 1 else { return } + displayLink.invalidate() + burnDisplayLink = nil + burnEmitterLayer?.birthRate = 0 + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { [weak self] in + self?.finishBurn() + } + } + + private func updateBurn(progress: CGFloat, timestamp: CFTimeInterval) { + let localPoints = burnBoundaryPoints(progress: progress, timestamp: timestamp) + let maskPath = UIBezierPath() + maskPath.move(to: .zero) + maskPath.addLine(to: CGPoint(x: letterView.bounds.width, y: 0)) + for point in localPoints.reversed() { + maskPath.addLine(to: point) + } + maskPath.close() + + let edgePath = UIBezierPath() + for (index, point) in localPoints.enumerated() { + let converted = CGPoint( + x: burnLetterFrame.minX + point.x, + y: burnLetterFrame.minY + point.y + ) + if index == 0 { + edgePath.move(to: converted) + } else { + edgePath.addLine(to: converted) + } + } + + let averageY = localPoints.reduce(CGFloat.zero) { $0 + $1.y } + / CGFloat(max(localPoints.count, 1)) + let edgeOpacity = Float(min(1, max(0, (1 - progress) * 5))) + + CATransaction.begin() + CATransaction.setDisableActions(true) + burnMaskLayer?.path = maskPath.cgPath + charEdgeLayer?.path = edgePath.cgPath + fireEdgeLayer?.path = edgePath.cgPath + hotEdgeLayer?.path = edgePath.cgPath + charEdgeLayer?.opacity = edgeOpacity + fireEdgeLayer?.opacity = edgeOpacity + hotEdgeLayer?.opacity = edgeOpacity + burnEmitterLayer?.emitterPosition = CGPoint( + x: burnLetterFrame.midX, + y: burnLetterFrame.minY + averageY + ) + CATransaction.commit() + } + + private func burnBoundaryPoints(progress: CGFloat, timestamp: CFTimeInterval) -> [CGPoint] { + let width = letterView.bounds.width + let height = letterView.bounds.height + if progress >= 0.995 { + return stride(from: CGFloat.zero, through: width, by: 9).map { + CGPoint(x: $0, y: 0) + } + } + + let baseY = height * (1 - progress) + let phase = CGFloat(timestamp) * 13 + var points: [CGPoint] = [] + var x: CGFloat = 0 + while x < width { + let wideWave = sin(x * 0.052 + phase) * 6 + let smallWave = sin(x * 0.17 - phase * 1.4) * 2.8 + let flicker = sin((x + phase * 18) * 0.31) * 1.8 + let y = min(height, max(0, baseY + wideWave + smallWave + flicker)) + points.append(CGPoint(x: x, y: y)) + x += 9 + } + points.append(CGPoint(x: width, y: min(height, max(0, baseY)))) + return points + } + + private func addBurningEdges() { + let charLayer = makeEdgeLayer( + color: UIColor(hexStr: "#2A1710", alpha: 0.8), + width: 10 + ) + let fireLayer = makeEdgeLayer( + color: UIColor(hexStr: "#FF5A20"), + width: 5.5 + ) + fireLayer.shadowColor = UIColor(hexStr: "#FF4B16").cgColor + fireLayer.shadowOpacity = 0.85 + fireLayer.shadowRadius = 7 + fireLayer.shadowOffset = .zero + + let hotLayer = makeEdgeLayer( + color: UIColor(hexStr: "#FFD75A"), + width: 1.8 + ) + + layer.addSublayer(charLayer) + layer.addSublayer(fireLayer) + layer.addSublayer(hotLayer) + effectLayers.append(contentsOf: [charLayer, fireLayer, hotLayer]) + charEdgeLayer = charLayer + fireEdgeLayer = fireLayer + hotEdgeLayer = hotLayer + } + + private func makeEdgeLayer(color: UIColor, width: CGFloat) -> CAShapeLayer { + let shapeLayer = CAShapeLayer() + shapeLayer.frame = bounds + shapeLayer.fillColor = UIColor.clear.cgColor + shapeLayer.strokeColor = color.cgColor + shapeLayer.lineWidth = width + shapeLayer.lineCap = .round + shapeLayer.lineJoin = .round + return shapeLayer + } + + private func addBurnParticles() { + let emitter = CAEmitterLayer() + emitter.frame = bounds + emitter.emitterShape = .line + emitter.emitterMode = .surface + emitter.emitterSize = CGSize(width: burnLetterFrame.width - 40, height: 1) + emitter.emitterPosition = CGPoint(x: burnLetterFrame.midX, y: burnLetterFrame.maxY) + + let ember = CAEmitterCell() + ember.contents = makeEmberImage() + ember.birthRate = 38 + ember.lifetime = 0.58 + ember.lifetimeRange = 0.18 + ember.velocity = 62 + ember.velocityRange = 34 + ember.emissionLongitude = -.pi / 2 + ember.emissionRange = .pi / 2.8 + ember.scale = 0.65 + ember.scaleRange = 0.3 + ember.spinRange = .pi * 2 + ember.alphaSpeed = -1.5 + ember.yAcceleration = -22 + + let ash = CAEmitterCell() + ash.contents = makeAshImage() + ash.birthRate = 22 + ash.lifetime = 0.8 + ash.lifetimeRange = 0.2 + ash.velocity = 38 + ash.velocityRange = 20 + ash.emissionLongitude = -.pi / 2 + ash.emissionRange = .pi / 2 + ash.scale = 0.7 + ash.scaleRange = 0.3 + ash.spin = 1.4 + ash.spinRange = .pi * 2 + ash.alphaSpeed = -1 + + let smoke = CAEmitterCell() + smoke.contents = makeParticleImage( + color: UIColor(hexStr: "#696969", alpha: 0.22), + size: CGSize(width: 11, height: 11) + ) + smoke.birthRate = 8 + smoke.lifetime = 0.9 + smoke.lifetimeRange = 0.25 + smoke.velocity = 30 + smoke.velocityRange = 16 + smoke.emissionLongitude = -.pi / 2 + smoke.emissionRange = .pi / 3 + smoke.scale = 0.55 + smoke.scaleRange = 0.2 + smoke.scaleSpeed = 0.12 + smoke.alphaSpeed = -0.7 + smoke.yAcceleration = -8 + + emitter.emitterCells = [ember, ash, smoke] + layer.addSublayer(emitter) + effectLayers.append(emitter) + burnEmitterLayer = emitter + } + + private func makeParticleImage(color: UIColor, size: CGSize) -> CGImage? { + UIGraphicsImageRenderer(size: size).image { context in + context.cgContext.setFillColor(color.cgColor) + context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: size)) + }.cgImage + } + + private func makeEmberImage() -> CGImage? { + let size = CGSize(width: 3, height: 8) + return UIGraphicsImageRenderer(size: size).image { _ in + UIColor(hexStr: "#FF982A").setFill() + UIBezierPath( + roundedRect: CGRect(origin: .zero, size: size), + cornerRadius: 1.5 + ).fill() + }.cgImage + } + + private func makeAshImage() -> CGImage? { + let size = CGSize(width: 5, height: 3) + return UIGraphicsImageRenderer(size: size).image { _ in + UIColor(hexStr: "#4B403A", alpha: 0.72).setFill() + let path = UIBezierPath() + path.move(to: CGPoint(x: 0, y: 2.4)) + path.addLine(to: CGPoint(x: 1.3, y: 0)) + path.addLine(to: CGPoint(x: 5, y: 1.1)) + path.addLine(to: CGPoint(x: 3.8, y: 3)) + path.close() + path.fill() + }.cgImage + } + + private func finishBurn() { + guard isBurning, let item else { return } + let completion = onBurn + if !messages.isEmpty { + messages.removeFirst() + } + + guard let nextItem = messages.first else { + dismiss(animated: false) + completion?(item) + return + } + + resetBurnState() + self.item = nextItem + configure(item: nextItem) + letterView.alpha = 0 + letterView.transform = CGAffineTransform(scaleX: 0.94, y: 0.94) + UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut]) { + self.letterView.alpha = 1 + self.letterView.transform = .identity + } + completion?(item) + } + + private func dismiss(animated: Bool) { + guard superview != nil else { return } + + let completion = { [weak self] in + guard let self else { return } + self.removeFromSuperview() + self.resetBurnState() + self.messages.removeAll() + self.item = nil + self.onBurn = nil + } + + guard animated else { + completion() + return + } + + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) { + self.overlayView.alpha = 0 + self.letterView.alpha = 0 + self.letterView.transform = CGAffineTransform(scaleX: 0.94, y: 0.94) + } completion: { _ in + completion() + } + } + + private func resetBurnState() { + burnDisplayLink?.invalidate() + burnDisplayLink = nil + isBurning = false + burnButton.isUserInteractionEnabled = true + letterView.alpha = 1 + letterView.transform = .identity + letterView.layer.transform = CATransform3DIdentity + letterView.layer.mask = nil + letterView.layer.removeAllAnimations() + letterView.layer.shouldRasterize = false + letterView.layer.shadowOpacity = 0 + letterView.layer.shadowRadius = 0 + letterView.layer.shadowOffset = .zero + for layer in effectLayers { + layer.removeAllAnimations() + layer.removeFromSuperlayer() + } + effectLayers.removeAll() + burnMaskLayer = nil + charEdgeLayer = nil + fireEdgeLayer = nil + hotEdgeLayer = nil + burnEmitterLayer = nil + burnLetterFrame = .zero + } +} diff --git a/QuickLocation/Section/Home/SOS/SOSAnimationViews.swift b/QuickLocation/Section/Home/SOS/SOSAnimationViews.swift new file mode 100644 index 00000000..48834767 --- /dev/null +++ b/QuickLocation/Section/Home/SOS/SOSAnimationViews.swift @@ -0,0 +1,619 @@ +// +// SOSAnimationViews.swift +// QuickLocation +// + +import UIKit + +final class SOSPulseButtonView: UIView { + + var onTap: (() -> Void)? + + private let buttonImageView = UIImageView(image: UIImage(named: "SOS/sos_main_button")) + private let titleImageView = UIImageView(image: UIImage(named: "SOS/sos_title")) + private let handImageView = UIImageView(image: UIImage(named: "SOS/sos_tap_hand")) + private let tapButton = UIButton(type: .custom) + private let rippleLayers = [CAShapeLayer(), CAShapeLayer()] + private var shouldAnimate = true + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isAccessibilityElement = false + + rippleLayers.forEach { + $0.fillColor = UIColor.clear.cgColor + $0.strokeColor = UIColor(hexStr: "#FF7378").cgColor + $0.lineWidth = 1.5 + $0.opacity = 0 + layer.addSublayer($0) + } + + buttonImageView.contentMode = .scaleAspectFit + addSubview(buttonImageView) + + titleImageView.contentMode = .scaleAspectFit + addSubview(titleImageView) + + handImageView.contentMode = .scaleAspectFit + addSubview(handImageView) + + tapButton.accessibilityLabel = "发送SOS" + tapButton.addTarget(self, action: #selector(handleTap), for: .touchUpInside) + addSubview(tapButton) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + CGSize(width: 345, height: 345) + } + + override func layoutSubviews() { + super.layoutSubviews() + + let side = min(bounds.width, bounds.height) + let center = CGPoint(x: bounds.midX, y: bounds.midY) + let buttonSide = side * 0.74 + buttonImageView.frame = CGRect( + x: center.x - buttonSide / 2, + y: center.y - buttonSide / 2, + width: buttonSide, + height: buttonSide + ) + + let rippleCenter = buttonImageView.center + let rippleStartRadius = buttonImageView.frame.width / 2 + rippleLayers.forEach { rippleLayer in + rippleLayer.frame = bounds + rippleLayer.path = UIBezierPath( + arcCenter: rippleCenter, + radius: rippleStartRadius, + startAngle: 0, + endAngle: .pi * 2, + clockwise: true + ).cgPath + } + + let titleWidth = buttonSide * 0.49 + let titleAspectRatio = titleImageView.image.map { $0.size.height / $0.size.width } ?? 0.32 + let titleHeight = titleWidth * titleAspectRatio + let titleCenterY = handImageView.isHidden + ? center.y + : center.y - buttonSide * 0.13 + titleImageView.frame = CGRect( + x: center.x - titleWidth / 2, + y: titleCenterY - titleHeight / 2, + width: titleWidth, + height: titleHeight + ) + + let handSize = CGSize(width: side * 0.09, height: side * 0.14) + handImageView.frame = CGRect( + x: center.x - handSize.width / 2, + y: center.y + buttonSide * 0.13, + width: handSize.width, + height: handSize.height + ) + tapButton.frame = bounds + } + + override func didMoveToWindow() { + super.didMoveToWindow() + window == nil ? stopAnimations() : startAnimationsIfNeeded() + } + + func setShowsHand(_ showsHand: Bool) { + handImageView.isHidden = !showsHand + setNeedsLayout() + if showsHand { + startAnimationsIfNeeded() + } else { + handImageView.layer.removeAnimation(forKey: "sos.hand.tap") + } + } + + func setAnimationEnabled(_ enabled: Bool) { + shouldAnimate = enabled + enabled ? startAnimationsIfNeeded() : stopAnimations() + } + + private func startAnimationsIfNeeded() { + guard shouldAnimate, window != nil, !UIAccessibility.isReduceMotionEnabled else { + stopAnimations() + return + } + + let travelDuration = 1.35 + let staggerDuration = travelDuration * 0.85 + let cycleDuration = staggerDuration * Double(rippleLayers.count) + let travelProgress = NSNumber(value: travelDuration / cycleDuration) + let baseTime = CACurrentMediaTime() + for (index, rippleLayer) in rippleLayers.enumerated() { + guard rippleLayer.animation(forKey: "sos.ripple") == nil else { continue } + + let rippleScale = CAKeyframeAnimation(keyPath: "transform.scale") + rippleScale.values = [1.0, 1.42, 1.42] + rippleScale.keyTimes = [0, travelProgress, 1] + + let opacity = CAKeyframeAnimation(keyPath: "opacity") + opacity.values = [0.58, 0, 0] + opacity.keyTimes = [0, travelProgress, 1] + + let rippleGroup = CAAnimationGroup() + rippleGroup.animations = [rippleScale, opacity] + rippleGroup.duration = cycleDuration + rippleGroup.beginTime = baseTime + Double(index) * staggerDuration + rippleGroup.repeatCount = Float.infinity + rippleGroup.isRemovedOnCompletion = false + rippleLayer.add(rippleGroup, forKey: "sos.ripple") + } + + guard !handImageView.isHidden, + handImageView.layer.animation(forKey: "sos.hand.tap") == nil else { return } + + let translation = CAKeyframeAnimation(keyPath: "transform.translation.y") + translation.values = [0, 5, 5, 0] + translation.keyTimes = [0, 0.35, 0.58, 1] + + let handScale = CAKeyframeAnimation(keyPath: "transform.scale") + handScale.values = [1, 0.9, 0.9, 1] + handScale.keyTimes = [0, 0.35, 0.58, 1] + + let handGroup = CAAnimationGroup() + handGroup.animations = [translation, handScale] + handGroup.duration = 1.15 + handGroup.repeatCount = Float.infinity + handImageView.layer.add(handGroup, forKey: "sos.hand.tap") + } + + private func stopAnimations() { + rippleLayers.forEach { $0.removeAnimation(forKey: "sos.ripple") } + handImageView.layer.removeAnimation(forKey: "sos.hand.tap") + } + + @objc private func handleTap() { + onTap?() + } +} + +final class SOSCountdownRingView: UIView { + + var onCompleted: (() -> Void)? + + private let trackLayer = CAShapeLayer() + private let progressMaskLayer = CAShapeLayer() + private let progressGradientLayer = CAGradientLayer() + private let rippleLayers = [CAShapeLayer(), CAShapeLayer()] + private let digitStack = UIStackView() + private let alarmContainer = UIView() + private let alarmRaysView = UIImageView(image: UIImage(named: "SOS/sos_alarm_rays")) + private let alarmIconView = UIImageView(image: UIImage(named: "SOS/sos_alarm_icon")) + + private var countdownTimer: Timer? + private var deadline: Date? + private var duration: TimeInterval = 10 + private var lastDisplayedValue = -1 + private var completionDelivered = false + private var progress: CGFloat = 1 + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + + rippleLayers.forEach { + $0.fillColor = UIColor.clear.cgColor + $0.strokeColor = UIColor(hexStr: "#F4B5EE").cgColor + $0.lineWidth = 1.5 + $0.opacity = 0 + layer.addSublayer($0) + } + + trackLayer.fillColor = UIColor.clear.cgColor + trackLayer.strokeColor = UIColor(hexStr: "#EEE8FF").cgColor + trackLayer.lineCap = .butt + layer.addSublayer(trackLayer) + + progressGradientLayer.type = .conic + progressGradientLayer.colors = [ + UIColor(hexStr: "#C9B7FF").cgColor, + UIColor(hexStr: "#9C5AFF").cgColor, + UIColor(hexStr: "#D72DD6").cgColor, + UIColor(hexStr: "#C9B7FF").cgColor + ] + progressGradientLayer.locations = [0, 0.38, 0.76, 1] + progressGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5) + progressGradientLayer.endPoint = CGPoint(x: 0.5, y: 0) + progressGradientLayer.mask = progressMaskLayer + layer.addSublayer(progressGradientLayer) + + digitStack.axis = .horizontal + digitStack.alignment = .center + digitStack.distribution = .fill + digitStack.spacing = 2 + addSubview(digitStack) + + alarmIconView.contentMode = .scaleAspectFit + alarmRaysView.contentMode = .scaleAspectFit + alarmContainer.addSubview(alarmIconView) + alarmContainer.addSubview(alarmRaysView) + addSubview(alarmContainer) + + render(progress: 1, value: 10) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + CGSize(width: 345, height: 345) + } + + override func layoutSubviews() { + super.layoutSubviews() + + let side = min(bounds.width, bounds.height) + let center = CGPoint(x: bounds.midX, y: bounds.midY) + let ringDiameter = side * 0.64 + let ringRadius = ringDiameter / 2 + let lineWidth = max(18, side * 0.064) + rippleLayers.forEach { rippleLayer in + rippleLayer.frame = bounds + rippleLayer.path = UIBezierPath( + arcCenter: center, + radius: ringRadius + lineWidth / 2, + startAngle: 0, + endAngle: .pi * 2, + clockwise: true + ).cgPath + } + + let ringPath = UIBezierPath( + arcCenter: center, + radius: ringRadius, + startAngle: -.pi / 2, + endAngle: .pi * 1.5, + clockwise: true + ) + + trackLayer.frame = bounds + trackLayer.path = ringPath.cgPath + trackLayer.lineWidth = lineWidth + + progressMaskLayer.frame = bounds + progressMaskLayer.path = ringPath.cgPath + progressMaskLayer.fillColor = UIColor.clear.cgColor + progressMaskLayer.strokeColor = UIColor.black.cgColor + progressMaskLayer.lineWidth = lineWidth + progressMaskLayer.lineCap = .butt + progressGradientLayer.frame = bounds + + layoutDigitStack(at: center) + + let alarmSide = side * 0.15 + let alarmSize = CGSize(width: alarmSide, height: alarmSide) + alarmContainer.bounds = CGRect(origin: .zero, size: alarmSize) + alarmIconView.frame = alarmContainer.bounds + alarmRaysView.frame = alarmContainer.bounds + updateAlarmPosition(progress: progress) + } + + func start(duration: TimeInterval = 10) { + stop(reset: false) + self.duration = max(0.1, duration) + deadline = Date().addingTimeInterval(self.duration) + completionDelivered = false + lastDisplayedValue = -1 + render(progress: 1, value: Int(ceil(self.duration))) + startAlarmAnimation() + + let timer = Timer(timeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in + self?.updateCountdown() + } + RunLoop.main.add(timer, forMode: .common) + countdownTimer = timer + } + + func stop(reset: Bool = true) { + countdownTimer?.invalidate() + countdownTimer = nil + deadline = nil + rippleLayers.forEach { $0.removeAnimation(forKey: "sos.countdown.ripple") } + alarmRaysView.layer.removeAnimation(forKey: "sos.alarm.rays") + alarmIconView.layer.removeAnimation(forKey: "sos.alarm.icon") + if reset { + completionDelivered = false + lastDisplayedValue = -1 + render(progress: 1, value: 10) + } + } + + private func updateCountdown() { + guard let deadline else { return } + let remaining = max(0, deadline.timeIntervalSinceNow) + let progress = CGFloat(min(1, remaining / duration)) + let value = max(0, min(10, Int(ceil(remaining)))) + render(progress: progress, value: value) + + guard remaining <= 0, !completionDelivered else { return } + completionDelivered = true + countdownTimer?.invalidate() + countdownTimer = nil + self.deadline = nil + rippleLayers.forEach { $0.removeAnimation(forKey: "sos.countdown.ripple") } + alarmRaysView.layer.removeAnimation(forKey: "sos.alarm.rays") + alarmIconView.layer.removeAnimation(forKey: "sos.alarm.icon") + onCompleted?() + } + + private func render(progress: CGFloat, value: Int) { + self.progress = progress + CATransaction.begin() + CATransaction.setDisableActions(true) + progressMaskLayer.strokeStart = 1 - progress + progressMaskLayer.strokeEnd = 1 + CATransaction.commit() + updateAlarmPosition(progress: progress) + + guard value != lastDisplayedValue else { return } + lastDisplayedValue = value + digitStack.arrangedSubviews.forEach { + digitStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + String(value).forEach { character in + let imageView = UIImageView(image: UIImage(named: "SOS/sos_countdown_\(character)")) + imageView.contentMode = .scaleAspectFit + imageView.widthAnchor.constraint(equalToConstant: 42).isActive = true + imageView.heightAnchor.constraint(equalToConstant: 58).isActive = true + digitStack.addArrangedSubview(imageView) + } + layoutDigitStack(at: CGPoint(x: bounds.midX, y: bounds.midY)) + bringSubviewToFront(digitStack) + } + + private func updateAlarmPosition(progress: CGFloat) { + guard bounds.width > 0, bounds.height > 0 else { return } + let side = min(bounds.width, bounds.height) + let radius = side * 0.64 / 2 + let angle = -.pi / 2 + .pi * 2 * (1 - progress) + alarmContainer.center = CGPoint( + x: bounds.midX + cos(angle) * radius, + y: bounds.midY + sin(angle) * radius - 5 + ) + bringSubviewToFront(alarmContainer) + } + + private func layoutDigitStack(at center: CGPoint) { + let digitWidth: CGFloat = 42 + let digitHeight: CGFloat = 58 + let count = digitStack.arrangedSubviews.count + let spacingWidth = CGFloat(max(0, count - 1)) * digitStack.spacing + digitStack.bounds = CGRect( + x: 0, + y: 0, + width: CGFloat(count) * digitWidth + spacingWidth, + height: digitHeight + ) + digitStack.center = center + digitStack.setNeedsLayout() + digitStack.layoutIfNeeded() + } + + private func startAlarmAnimation() { + guard !UIAccessibility.isReduceMotionEnabled else { return } + + let travelDuration = 1.35 + let staggerDuration = travelDuration * 0.85 + let cycleDuration = staggerDuration * Double(rippleLayers.count) + let travelProgress = NSNumber(value: travelDuration / cycleDuration) + let baseTime = CACurrentMediaTime() + for (index, rippleLayer) in rippleLayers.enumerated() { + let rippleScale = CAKeyframeAnimation(keyPath: "transform.scale") + rippleScale.values = [1.0, 1.55, 1.55] + rippleScale.keyTimes = [0, travelProgress, 1] + + let rippleOpacity = CAKeyframeAnimation(keyPath: "opacity") + rippleOpacity.values = [0.58, 0, 0] + rippleOpacity.keyTimes = [0, travelProgress, 1] + + let rippleGroup = CAAnimationGroup() + rippleGroup.animations = [rippleScale, rippleOpacity] + rippleGroup.duration = cycleDuration + rippleGroup.beginTime = baseTime + Double(index) * staggerDuration + rippleGroup.repeatCount = .infinity + rippleGroup.isRemovedOnCompletion = false + rippleLayer.add(rippleGroup, forKey: "sos.countdown.ripple") + } + + let opacity = CAKeyframeAnimation(keyPath: "opacity") + opacity.values = [0.25, 1, 0.25] + opacity.keyTimes = [0, 0.5, 1] + + let scale = CAKeyframeAnimation(keyPath: "transform.scale") + scale.values = [0.88, 1.08, 0.88] + scale.keyTimes = [0, 0.5, 1] + + let group = CAAnimationGroup() + group.animations = [opacity, scale] + group.duration = 0.62 + group.repeatCount = .infinity + alarmRaysView.layer.add(group, forKey: "sos.alarm.rays") + + let rotation = CAKeyframeAnimation(keyPath: "transform.rotation.z") + rotation.values = [-CGFloat.pi / 24, CGFloat.pi / 24, -CGFloat.pi / 24] + rotation.keyTimes = [0, 0.5, 1] + + let iconGroup = CAAnimationGroup() + iconGroup.animations = [rotation] + iconGroup.duration = 0.62 + iconGroup.repeatCount = .infinity + iconGroup.isRemovedOnCompletion = false + alarmIconView.layer.add(iconGroup, forKey: "sos.alarm.icon") + } + + deinit { + countdownTimer?.invalidate() + } +} + +final class SOSSlideCancelView: UIView { + + enum Style { + case practiceCountdown + case countdown + case active + } + + var onCompleted: (() -> Void)? + + private let gradientLayer = CAGradientLayer() + private let titleLabel = UILabel() + private let thumbView = UIImageView(image: UIImage(named: "SOS/sos_slider_arrow")) + private var style: Style = .countdown + private var startX: CGFloat = 3 + private var isDragging = false + private var isLocked = false + + override init(frame: CGRect) { + super.init(frame: frame) + layer.insertSublayer(gradientLayer, at: 0) + clipsToBounds = true + + titleLabel.text = "滑动取消sos" + titleLabel.textColor = .white + titleLabel.font = .systemFont(ofSize: 18, weight: .semibold) + titleLabel.textAlignment = .center + addSubview(titleLabel) + + thumbView.contentMode = .scaleAspectFit + thumbView.isUserInteractionEnabled = true + addSubview(thumbView) + + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + thumbView.addGestureRecognizer(pan) + isAccessibilityElement = true + accessibilityLabel = "滑动取消SOS" + accessibilityTraits = .adjustable + + configure(style: .countdown) + } + + convenience init(thumbImageName: String) { + self.init(frame: .zero) + thumbView.image = UIImage(named: thumbImageName) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + gradientLayer.frame = bounds + gradientLayer.cornerRadius = bounds.height / 2 + titleLabel.frame = bounds + + let thumbSide = max(0, bounds.height - 10) + thumbView.frame = CGRect(x: 10, y: 5, width: thumbSide, height: thumbSide) + } + + func configure(style: Style) { + self.style = style + switch style { + case .practiceCountdown: + gradientLayer.colors = [ + UIColor(hexStr: "#08ADEF").cgColor, + UIColor(hexStr: "#43C7F8").cgColor + ] + case .countdown, .active: + gradientLayer.colors = [ + UIColor(hexStr: "#293445").cgColor, + UIColor(hexStr: "#293445").cgColor + ] + } + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + reset(animated: false) + } + + func setInteractionEnabled(_ enabled: Bool) { + isLocked = !enabled + alpha = enabled ? 1 : 0.65 + thumbView.isUserInteractionEnabled = enabled + } + + func reset(animated: Bool = true) { + isLocked = false + isDragging = false + thumbView.isUserInteractionEnabled = true + alpha = 1 + let changes = { + self.thumbView.frame.origin.x = 2 + } + if animated { + UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut], animations: changes) + } else { + changes() + } + } + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + guard !isLocked else { return } + let maxX = max(2, bounds.width - thumbView.bounds.width - 2) + + switch gesture.state { + case .began: + isDragging = true + startX = thumbView.frame.minX + case .changed: + let translation = gesture.translation(in: self) + thumbView.frame.origin.x = min(maxX, max(2, startX + translation.x)) + case .ended, .cancelled, .failed: + isDragging = false + if thumbView.frame.minX >= maxX - 4 { + isLocked = true + thumbView.isUserInteractionEnabled = false + onCompleted?() + } else { + reset() + } + default: + break + } + } +} + +final class SOSGradientButton: UIButton { + + private let gradientLayer = CAGradientLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + gradientLayer.colors = [ + UIColor(hexStr: "#05AEF2").cgColor, + UIColor(hexStr: "#42C5F7").cgColor + ] + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + layer.insertSublayer(gradientLayer, at: 0) + setTitleColor(.white, for: .normal) + titleLabel?.font = .systemFont(ofSize: 18, weight: .bold) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + layer.cornerRadius = bounds.height / 2 + gradientLayer.cornerRadius = bounds.height / 2 + clipsToBounds = true + } +} diff --git a/QuickLocation/Section/Home/SOS/SOSPracticeView.swift b/QuickLocation/Section/Home/SOS/SOSPracticeView.swift index 45e5cc3c..bb7bc94b 100644 --- a/QuickLocation/Section/Home/SOS/SOSPracticeView.swift +++ b/QuickLocation/Section/Home/SOS/SOSPracticeView.swift @@ -2,438 +2,518 @@ // SOSPracticeView.swift // QuickLocation // -// Created by 八条 on 2026/6/18. -// import UIKit -import RxSwift -import RxCocoa -import RxGesture -import Lottie import SwiftyUserDefaults -class SOSPracticeView: UIView { +final class SOSPracticeView: UIView, UIScrollViewDelegate { - var disposeBag = DisposeBag() - - var countDownFinish: Bool = false - - private func setupRx() { - exclamationLottieView.rx.tapGesture.subscribe(onNext: { _ in - UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseInOut], animations: { - self.countDownView.alpha = 1 - }, completion: { _ in - self.countDownLottieView.play { completed in - guard completed else { return } - if let path = Bundle.main.path(forResource: "red-exclamation", ofType: "json") { - self.countDownLottieView.animation = LottieAnimation.filepath(path) - self.countDownLottieView.loopMode = .loop - self.countDownLottieView.play() - self.countDownTipsLab.text = "已将你的SOS和位置发送到你的圈子和紧急联系人。" - self.countDownFinish = true - } - } - }) - }).disposed(by: disposeBag) - - sliderIcon.rx.panGesture() - .subscribe(onNext: { [weak self] gesture in - guard let self = self else { return } - let translation = gesture.translation(in: self.sliderView) - let maxX = self.sliderView.bounds.width - self.sliderIcon.frame.width - - switch gesture.state { - case .began, .changed: - var newX = self.sliderIcon.frame.minX + translation.x - newX = max(0, min(maxX, newX)) - self.sliderIcon.frame.origin.x = newX - gesture.setTranslation(.zero, in: self.sliderView) - case .ended: - if self.sliderIcon.frame.minX >= maxX - 5 { - if countDownFinish { - UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseInOut]) { - self.finishView.alpha = 1 - } - } - else { - self.countDownView.alpha = 0 - self.countDownLottieView.stop() - } - } - UIView.animate(withDuration: 0.3) { - self.sliderIcon.frame.origin.x = 3 - } - - default: - break - } - }) - .disposed(by: disposeBag) + private enum State { + case guide + case practiceIdle + case practiceCountdown + case success } - - private func setupUI() { - addSubview(titleView) - addSubview(exclamationLottieView) - addSubview(tipsLab) - addSubview(bottomTipsLab) - addSubview(scrollView) - addSubview(pageControl) - addSubview(countDownView) - addSubview(finishView) - - titleView.layoutChain - .top(15) - .centerX() - - exclamationLottieView.layoutChain - .topToBottomOfView(titleView, offset: 13) - .edgesHorzontal(28) - .heightToWidth(1) - - tipsLab.layoutChain - .topToBottomOfView(exclamationLottieView, offset: 60) - .centerX() - - bottomTipsLab.layoutChain - .centerX() - .bottom(kSafeBottomMargin + 40) - - scrollView.layoutChain - .top(15) - .edges(excludingEdge: .top) - - pageControl.layoutChain - .centerX() - .bottom(kSafeBottomMargin + 80) - - countDownView.layoutChain - .edges() - - finishView.layoutChain.edges() - } - - lazy var titleView: UIView = { - let view = UIView() - view.backgroundColor = .clear - - view.addSubview(titleLab) - titleLab.layoutChain - .edgesVertical(9) - .edgesHorzontal(38) - - return view - }() - - lazy var titleLab: UILabel = { - let label = UILabel() - label.text = "开始练习" - label.font = .systemFont(ofSize: 16, weight: .medium) - label.textColor = .white - label.textAlignment = .center - return label - }() - - lazy var exclamationLottieView: LottieAnimationView = { - let view = LottieAnimationView(name: "yellow-exclamation") - view.loopMode = .loop - return view - }() - - lazy var tipsLab: UILabel = { - let label = UILabel() - label.text = "当您感到紧张或不安全时,请按住此按钮。" - label.font = .systemFont(ofSize: 16, weight: .medium) - label.textColor = UIColor(hexStr: "#333333") - return label - }() - - lazy var bottomTipsLab: UILabel = { - let label = UILabel() - label.textColor = UIColor(hexStr: "#333333") - let text = "轻点感叹号发送SOS" - let attr = NSMutableAttributedString(string: text) - attr.addAttribute(.font, value: UIFont.systemFont(ofSize: 20, weight: .semibold), range: NSRange(location: 0, length: text.count)) - attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#FF383C"), range: NSRange(location: "轻点感叹号发送".count, length: "SOS".count)) - label.attributedText = attr - return label - }() - - lazy var scrollView: UIScrollView = { - let view = UIScrollView() - view.backgroundColor = .white - view.isPagingEnabled = true - view.showsHorizontalScrollIndicator = false - view.bounces = false - view.delegate = self - - view.addSubview(scrollContentView) - scrollContentView.layoutChain.edges().heightToView(view) - - let view1 = UIView() - view1.backgroundColor = .clear - let img1 = UIImageView(image: UIImage(named: "SOS/1")) - let nextBtn = UIButton(type: .custom) - nextBtn.setTitle("开始设置", for: .normal) - nextBtn.setTitleColor(.white, for: .normal) - nextBtn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) - nextBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) - nextBtn.cornerRadius = 25 - nextBtn.rx.tap.subscribe(onNext: { _ in - view.setContentOffset(CGPointMake(kScreenWidth, 0), animated: true) - self.pageControl.currentPage = 1 - }).disposed(by: disposeBag) - - view1.addSubview(img1) - view1.addSubview(nextBtn) - scrollContentView.addSubview(view1) - view1.layoutChain.edges(excludingEdge: .right).width(kScreenWidth) - img1.layoutChain - .top() - .edgesHorzontal(39) - .heightToWidth(471/297) - - nextBtn.layoutChain - .height(50) - .edgesHorzontal(30) - .bottom(kSafeBottomMargin + 20) - - let view2 = UIView() - view2.backgroundColor = .clear - let img2 = UIImageView(image: UIImage(named: "SOS/2")) - view2.addSubview(img2) - scrollContentView.addSubview(view2) - view2.layoutChain.top().bottom().leftToRightOfView(view1).widthToView(view1) - img2.layoutChain - .top() - .edgesHorzontal(39) - .heightToWidth(471/297) - - let view3 = UIView() - view3.backgroundColor = .clear - let img3 = UIImageView(image: UIImage(named: "SOS/3")) - let doneBtn = UIButton(type: .custom) - doneBtn.setTitle("练习SOS触发", for: .normal) - doneBtn.setTitleColor(.white, for: .normal) - doneBtn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) - doneBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) - doneBtn.cornerRadius = 25 - doneBtn.rx.tap.subscribe(onNext: { _ in - self.scrollView.isHidden = true - self.pageControl.isHidden = true - }).disposed(by: disposeBag) - - view3.addSubview(img3) - view3.addSubview(doneBtn) - scrollContentView.addSubview(view3) - view3.layoutChain.top().bottom().right().leftToRightOfView(view2).widthToView(view1) - img3.layoutChain - .top() - .edgesHorzontal(39) - .heightToWidth(471/297) - - doneBtn.layoutChain - .height(50) - .edgesHorzontal(30) - .bottom(kSafeBottomMargin + 20) - - return view - }() - - lazy var scrollContentView: UIView = { - let view = UIView() - view.backgroundColor = .clear - return view - }() - - lazy var pageControl: UIPageControl = { - let pc = UIPageControl() - pc.numberOfPages = 3 - pc.currentPageIndicatorTintColor = UIColor(hexStr: "#57C7FF") - pc.pageIndicatorTintColor = UIColor(hexStr: "#D9D9D9", alpha: 1) - return pc - }() - - // 倒计时 - lazy var countDownView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.alpha = 0 - - view.addSubview(countDownTitleView) - countDownTitleView.layoutChain - .top(15) - .centerX() - - view.addSubview(countDownLottieView) - countDownLottieView.layoutChain - .topToBottomOfView(countDownTitleView, offset: 13) - .edgesHorzontal(27) - .heightToWidth(1) - - let titleLab = UILabel() - titleLab.text = "滑动取消" - titleLab.font = .systemFont(ofSize: 26, weight: .semibold) - view.addSubview(titleLab) - titleLab.layoutChain - .topToBottomOfView(countDownLottieView, offset: 15) - .centerX() - - view.addSubview(countDownTipsLab) - countDownTipsLab.layoutChain - .topToBottomOfView(titleLab, offset: 8) - .edgesHorzontal(38) - - view.addSubview(sliderView) - sliderView.layoutChain - .edgesHorzontal(30) - .bottom(kSafeBottomMargin + 30) - .height(50) - - return view - }() - - lazy var countDownTitleView: UIView = { - let view = UIView() - view.backgroundColor = .clear - - view.addSubview(countDownTitleLab) - countDownTitleLab.layoutChain - .edgesVertical(9) - .edgesHorzontal(38) - - return view - }() - - lazy var countDownTitleLab: UILabel = { - let label = UILabel() - label.text = "练习模式" - label.font = .systemFont(ofSize: 16, weight: .medium) - label.textColor = .white - label.textAlignment = .center - return label - }() - - lazy var countDownLottieView: LottieAnimationView = { - let view = LottieAnimationView(name: "10-second-timer") - view.loopMode = .playOnce - return view - }() - - lazy var countDownTipsLab: UILabel = { - let tipsLab = UILabel() - tipsLab.text = "10秒后,会将你的SOS和位置发送到你的圈子和紧急联系人。" - tipsLab.font = .systemFont(ofSize: 16, weight: .medium) - tipsLab.textAlignment = .center - tipsLab.numberOfLines = 0 - - return tipsLab - }() - - lazy var sliderView: UIView = { - let view = UIView() - view.backgroundColor = .clear - view.cornerRadius = 25 - - let imgView = UIImageView(image: UIImage(named: "Common/button_bg_2")) - imgView.contentMode = .scaleAspectFill - view.addSubview(imgView) - imgView.layoutChain.edges() - - let label = UILabel() - label.text = "滑动以取消SOS" - label.textColor = .white - label.font = .systemFont(ofSize: 16, weight: .medium) - view.addSubview(label) - label.layoutChain.centerX().centerY() - - view.addSubview(sliderIcon) - - return view - }() - - lazy var sliderIcon: UIImageView = { - let view = UIImageView(frame: CGRectMake(3, 2, 46, 46)) - view.image = UIImage(named: "SOS/slider") - view.isUserInteractionEnabled = true - return view - }() - - lazy var finishView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.alpha = 0 - - let imgView = UIImageView(image: UIImage(named: "SOS/finish")) - imgView.contentMode = .scaleAspectFill - view.addSubview(imgView) - imgView.layoutChain - .top(50) - .edgesHorzontal(70) - .heightToWidth(356/235) - - let doneBtn = UIButton(type: .custom) - doneBtn.setTitle("收到了", for: .normal) - doneBtn.setTitleColor(.white, for: .normal) - doneBtn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) - doneBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) - doneBtn.cornerRadius = 25 - doneBtn.rx.tap.subscribe(onNext: { _ in - var sosIsPracticeList = Defaults[\.sosIsPracticeList] - sosIsPracticeList.append(AppContextManager.shared.userId) - Defaults[\.sosIsPracticeList] = sosIsPracticeList - UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseInOut], animations: { - self.alpha = 0 - }, completion: { _ in - self.isHidden = true - }) - }).disposed(by: disposeBag) - view.addSubview(doneBtn) - doneBtn.layoutChain - .height(50) - .edgesHorzontal(30) - .bottom(kSafeBottomMargin + 20) - - return view - }() - + + private let guideView = UIView() + private let practiceIdleView = UIView() + private let practiceCountdownView = UIView() + private let successView = UIView() + private let guideScrollView = UIScrollView() + private let pageControl = UIPageControl() + private let practicePulseView = SOSPulseButtonView() + private let practiceCountdownRing = SOSCountdownRingView() + private let practiceSlider = SOSSlideCancelView(thumbImageName: "SOS/slider") + private var state: State = .guide + override init(frame: CGRect) { - super.init(frame: .zero) - self.isHidden = true + super.init(frame: frame) backgroundColor = .white setupUI() - setupRx() - - exclamationLottieView.play() + setupActions() + setState(.guide) } - required init?(coder aDecoder: NSCoder) { + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func layoutSubviews() { - super.layoutSubviews() - self.layoutIfNeeded() - - titleView.setGradientLayer(frame: titleView.bounds, - startPoint: CGPoint(x: 0, y: 0.5), - endPoint: CGPoint(x: 1, y: 0.5), - colors: [UIColor(hexStr: "#5CBBFF", alpha: 0), UIColor(hexStr: "#5CBBFF"), UIColor(hexStr: "#5CBBFF", alpha: 0)], - locations: [0, 0.5, 1]) - - countDownView.layoutIfNeeded() - countDownTitleView.setGradientLayer(frame: countDownTitleView.bounds, - startPoint: CGPoint(x: 0, y: 0.5), - endPoint: CGPoint(x: 1, y: 0.5), - colors: [UIColor(hexStr: "#5CBBFF", alpha: 0), UIColor(hexStr: "#5CBBFF"), UIColor(hexStr: "#5CBBFF", alpha: 0)], - locations: [0, 0.5, 1]) + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { + practiceCountdownRing.stop() + } + } + + private func setupUI() { + [guideView, practiceIdleView, practiceCountdownView, successView].forEach { + addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + $0.topAnchor.constraint(equalTo: topAnchor), + $0.leadingAnchor.constraint(equalTo: leadingAnchor), + $0.trailingAnchor.constraint(equalTo: trailingAnchor), + $0.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + setupGuideView() + setupPracticeIdleView() + setupPracticeCountdownView() + setupSuccessView() + } + + private func setupGuideView() { + guideScrollView.isPagingEnabled = true + guideScrollView.showsHorizontalScrollIndicator = false + guideScrollView.bounces = false + guideScrollView.delegate = self + guideView.addSubview(guideScrollView) + guideScrollView.translatesAutoresizingMaskIntoConstraints = false + + let pageStack = UIStackView() + pageStack.axis = .horizontal + pageStack.alignment = .fill + pageStack.distribution = .fill + pageStack.spacing = 0 + guideScrollView.addSubview(pageStack) + pageStack.translatesAutoresizingMaskIntoConstraints = false + + for index in 1...3 { + let page = makeGuidePage(index: index) + pageStack.addArrangedSubview(page) + page.widthAnchor.constraint(equalTo: guideScrollView.frameLayoutGuide.widthAnchor).isActive = true + } + + pageControl.numberOfPages = 3 + pageControl.currentPage = 0 + pageControl.currentPageIndicatorTintColor = UIColor(hexStr: "#4CC7F8") + pageControl.pageIndicatorTintColor = UIColor(hexStr: "#D9D9D9") + pageControl.isUserInteractionEnabled = false + guideView.addSubview(pageControl) + pageControl.translatesAutoresizingMaskIntoConstraints = false + + let startButton = UIButton(type: .custom) + startButton.setTitle("开始练习", for: .normal) + startButton.setTitleColor(.white, for: .normal) + startButton.titleLabel?.font = FontManager.boboBold(18) + startButton.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + startButton.cornerRadius = 20 + startButton.addTarget(self, action: #selector(startPractice), for: .touchUpInside) + guideView.addSubview(startButton) + startButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + guideScrollView.topAnchor.constraint(equalTo: guideView.topAnchor), + guideScrollView.leadingAnchor.constraint(equalTo: guideView.leadingAnchor), + guideScrollView.trailingAnchor.constraint(equalTo: guideView.trailingAnchor), + guideScrollView.bottomAnchor.constraint(equalTo: pageControl.topAnchor, constant: -8), + + pageStack.topAnchor.constraint(equalTo: guideScrollView.contentLayoutGuide.topAnchor), + pageStack.leadingAnchor.constraint(equalTo: guideScrollView.contentLayoutGuide.leadingAnchor), + pageStack.trailingAnchor.constraint(equalTo: guideScrollView.contentLayoutGuide.trailingAnchor), + pageStack.bottomAnchor.constraint(equalTo: guideScrollView.contentLayoutGuide.bottomAnchor), + pageStack.heightAnchor.constraint(equalTo: guideScrollView.frameLayoutGuide.heightAnchor), + + pageControl.centerXAnchor.constraint(equalTo: guideView.centerXAnchor), + pageControl.bottomAnchor.constraint(equalTo: startButton.topAnchor, constant: -14), + + startButton.leadingAnchor.constraint(equalTo: guideView.leadingAnchor, constant: 30), + startButton.trailingAnchor.constraint(equalTo: guideView.trailingAnchor, constant: -30), + startButton.heightAnchor.constraint(equalToConstant: 56), + startButton.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -20) + ]) + } + + private func makeGuidePage(index: Int) -> UIView { + let page = UIView() + + let verticalScrollView = UIScrollView() + verticalScrollView.showsVerticalScrollIndicator = false + verticalScrollView.alwaysBounceVertical = false + page.addSubview(verticalScrollView) + verticalScrollView.translatesAutoresizingMaskIntoConstraints = false + + let contentView = UIView() + verticalScrollView.addSubview(contentView) + contentView.translatesAutoresizingMaskIntoConstraints = false + + let imageView = UIImageView(image: UIImage(named: "SOS/sos_guide_\(index)")) + imageView.contentMode = .scaleAspectFit + contentView.addSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false + + let bulletList = makeBulletList() + contentView.addSubview(bulletList) + bulletList.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + verticalScrollView.topAnchor.constraint(equalTo: page.topAnchor), + verticalScrollView.leadingAnchor.constraint(equalTo: page.leadingAnchor), + verticalScrollView.trailingAnchor.constraint(equalTo: page.trailingAnchor), + verticalScrollView.bottomAnchor.constraint(equalTo: page.bottomAnchor), + + contentView.topAnchor.constraint(equalTo: verticalScrollView.contentLayoutGuide.topAnchor), + contentView.leadingAnchor.constraint(equalTo: verticalScrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: verticalScrollView.contentLayoutGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: verticalScrollView.contentLayoutGuide.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: verticalScrollView.frameLayoutGuide.widthAnchor), + contentView.heightAnchor.constraint(greaterThanOrEqualTo: verticalScrollView.frameLayoutGuide.heightAnchor), + + imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 36), + imageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + imageView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -75), + imageView.widthAnchor.constraint(lessThanOrEqualToConstant: 300), + imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor), + + bulletList.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 28), + bulletList.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 45), + bulletList.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -30), + bulletList.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -16) + ]) + return page + } + + private func makeBulletList() -> UIStackView { + let stack = UIStackView() + stack.axis = .vertical + stack.alignment = .leading + stack.distribution = .fillEqually + stack.spacing = 10 + + ["夜间独自行走", "遇见突发危机事件", "遇到医疗紧急情况"].forEach { text in + let label = UILabel() + label.textColor = UIColor(hexStr: "#333333") + label.font = .systemFont(ofSize: 16, weight: .regular) + let attributed = NSMutableAttributedString(string: "✦ \(text)") + attributed.addAttribute( + .foregroundColor, + value: UIColor(hexStr: "#11B8F4"), + range: NSRange(location: 0, length: 1) + ) + label.attributedText = attributed + stack.addArrangedSubview(label) + } + return stack + } + + private func setupPracticeIdleView() { + let contentView = makeVerticalContentView(in: practiceIdleView) + let modeBadge = SOSPracticeModeBadge() + contentView.addSubview(modeBadge) + modeBadge.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(practicePulseView) + practicePulseView.translatesAutoresizingMaskIntoConstraints = false + + let hintLabel = UILabel() + hintLabel.textAlignment = .center + hintLabel.font = .systemFont(ofSize: 20, weight: .regular) + let hint = NSMutableAttributedString( + string: "轻点感叹号发送SOS", + attributes: [.foregroundColor: UIColor(hexStr: "#333333")] + ) + hint.addAttribute( + .foregroundColor, + value: UIColor(hexStr: "#FF383C"), + range: NSRange(location: 7, length: 3) + ) + hintLabel.attributedText = hint + contentView.addSubview(hintLabel) + hintLabel.translatesAutoresizingMaskIntoConstraints = false + + let pulseWidth = practicePulseView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30) + pulseWidth.priority = .defaultHigh + NSLayoutConstraint.activate([ + modeBadge.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 28), + modeBadge.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + modeBadge.widthAnchor.constraint(equalToConstant: 139), + modeBadge.heightAnchor.constraint(equalToConstant: 40), + + practicePulseView.topAnchor.constraint(equalTo: modeBadge.bottomAnchor, constant: 18), + practicePulseView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + pulseWidth, + practicePulseView.widthAnchor.constraint(lessThanOrEqualToConstant: 345), + practicePulseView.heightAnchor.constraint(equalTo: practicePulseView.widthAnchor), + + hintLabel.topAnchor.constraint(equalTo: practicePulseView.bottomAnchor, constant: 16), + hintLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + hintLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -20) + ]) + } + + private func setupPracticeCountdownView() { + let contentView = makeVerticalContentView(in: practiceCountdownView) + let modeBadge = SOSPracticeModeBadge() + contentView.addSubview(modeBadge) + modeBadge.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(practiceCountdownRing) + practiceCountdownRing.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = UILabel() + titleLabel.attributedText = Self.cancelTitle + titleLabel.font = .systemFont(ofSize: 22, weight: .regular) + titleLabel.textAlignment = .center + contentView.addSubview(titleLabel) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + let bodyLabel = UILabel() + bodyLabel.text = "10秒后,会将你的SOS和位置发送到你的圈子和紧急联系人。" + bodyLabel.textColor = UIColor(hexStr: "#273246") + bodyLabel.font = .systemFont(ofSize: 15, weight: .regular) + bodyLabel.textAlignment = .center + bodyLabel.numberOfLines = 0 + contentView.addSubview(bodyLabel) + bodyLabel.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(practiceSlider) + practiceSlider.translatesAutoresizingMaskIntoConstraints = false + + let ringWidth = practiceCountdownRing.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30) + ringWidth.priority = .defaultHigh + NSLayoutConstraint.activate([ + modeBadge.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 28), + modeBadge.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + modeBadge.widthAnchor.constraint(equalToConstant: 139), + modeBadge.heightAnchor.constraint(equalToConstant: 40), + + practiceCountdownRing.topAnchor.constraint(equalTo: modeBadge.bottomAnchor, constant: 8), + practiceCountdownRing.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + ringWidth, + practiceCountdownRing.widthAnchor.constraint(lessThanOrEqualToConstant: 345), + practiceCountdownRing.heightAnchor.constraint(equalTo: practiceCountdownRing.widthAnchor), + + titleLabel.topAnchor.constraint(equalTo: practiceCountdownRing.bottomAnchor, constant: 2), + titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + + bodyLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 24), + bodyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 40), + bodyLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -40), + + practiceSlider.topAnchor.constraint(greaterThanOrEqualTo: bodyLabel.bottomAnchor, constant: 28), + practiceSlider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 30), + practiceSlider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -30), + practiceSlider.heightAnchor.constraint(equalToConstant: 56), + practiceSlider.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -(kSafeBottomMargin + 20)) + ]) + } + + private func setupSuccessView() { + let contentView = makeVerticalContentView(in: successView) + let modeBadge = SOSPracticeModeBadge() + contentView.addSubview(modeBadge) + modeBadge.translatesAutoresizingMaskIntoConstraints = false + + let imageView = UIImageView(image: UIImage(named: "SOS/sos_practice_success")) + imageView.contentMode = .scaleAspectFit + contentView.addSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = UILabel() + titleLabel.textAlignment = .center + titleLabel.font = .systemFont(ofSize: 22, weight: .regular) + let title = NSMutableAttributedString( + string: "求救成功", + attributes: [.foregroundColor: UIColor(hexStr: "#333333")] + ) + title.addAttribute( + .foregroundColor, + value: UIColor(hexStr: "#FF383C"), + range: NSRange(location: 2, length: 2) + ) + titleLabel.attributedText = title + contentView.addSubview(titleLabel) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + let subtitleLabel = UILabel() + subtitleLabel.text = "太好了!您可以使用sos了" + subtitleLabel.textColor = UIColor(hexStr: "#273246") + subtitleLabel.font = .systemFont(ofSize: 16, weight: .regular) + subtitleLabel.textAlignment = .center + contentView.addSubview(subtitleLabel) + subtitleLabel.translatesAutoresizingMaskIntoConstraints = false + + let doneButton = UIButton(type: .custom) + doneButton.setTitle("我知道了", for: .normal) + doneButton.setTitleColor(.white, for: .normal) + doneButton.titleLabel?.font = FontManager.boboBold(18) + doneButton.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + doneButton.cornerRadius = 20 + doneButton.addTarget(self, action: #selector(finishPractice), for: .touchUpInside) + contentView.addSubview(doneButton) + doneButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + modeBadge.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 28), + modeBadge.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + modeBadge.widthAnchor.constraint(equalToConstant: 139), + modeBadge.heightAnchor.constraint(equalToConstant: 40), + + imageView.topAnchor.constraint(equalTo: modeBadge.bottomAnchor, constant: 72), + imageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + imageView.widthAnchor.constraint(equalToConstant: 205), + imageView.heightAnchor.constraint(equalToConstant: 205), + + titleLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 52), + titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + + subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 34), + subtitleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + + doneButton.topAnchor.constraint(greaterThanOrEqualTo: subtitleLabel.bottomAnchor, constant: 30), + doneButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 30), + doneButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -30), + doneButton.heightAnchor.constraint(equalToConstant: 56), + doneButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -(kSafeBottomMargin + 20)) + ]) + } + + private func makeVerticalContentView(in container: UIView) -> UIView { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.alwaysBounceVertical = false + container.addSubview(scrollView) + scrollView.translatesAutoresizingMaskIntoConstraints = false + + let contentView = UIView() + scrollView.addSubview(contentView) + contentView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: container.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + + contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + contentView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) + ]) + return contentView + } + + private func setupActions() { + practicePulseView.onTap = { [weak self] in + guard let self, self.state == .practiceIdle else { return } + self.setState(.practiceCountdown) + } + practiceCountdownRing.onCompleted = { [weak self] in + guard let self, self.state == .practiceCountdown else { return } + self.setState(.success) + } + practiceSlider.onCompleted = { [weak self] in + guard let self, self.state == .practiceCountdown else { return } + self.practiceCountdownRing.stop() + self.setState(.practiceIdle) + } + } + + private func setState(_ state: State) { + self.state = state + guideView.isHidden = state != .guide + practiceIdleView.isHidden = state != .practiceIdle + practiceCountdownView.isHidden = state != .practiceCountdown + successView.isHidden = state != .success + + switch state { + case .guide: + practiceCountdownRing.stop() + practicePulseView.setAnimationEnabled(false) + case .practiceIdle: + practiceCountdownRing.stop() + practiceSlider.configure(style: .practiceCountdown) + practiceSlider.setInteractionEnabled(true) + practicePulseView.setShowsHand(true) + practicePulseView.setAnimationEnabled(true) + case .practiceCountdown: + practicePulseView.setAnimationEnabled(false) + practiceSlider.configure(style: .practiceCountdown) + practiceSlider.setInteractionEnabled(true) + practiceCountdownRing.start(duration: 10) + case .success: + practiceCountdownRing.stop(reset: false) + practicePulseView.setAnimationEnabled(false) + } + } + + @objc private func startPractice() { + setState(.practiceIdle) + } + + @objc private func finishPractice() { + var completedUsers = Defaults[\.sosIsPracticeList] + if !completedUsers.contains(AppContextManager.shared.userId) { + completedUsers.append(AppContextManager.shared.userId) + Defaults[\.sosIsPracticeList] = completedUsers + } + + UIView.animate(withDuration: 0.25, animations: { + self.alpha = 0 + }, completion: { _ in + self.isHidden = true + self.alpha = 1 + }) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + guard scrollView === guideScrollView, scrollView.bounds.width > 0 else { return } + pageControl.currentPage = Int(round(scrollView.contentOffset.x / scrollView.bounds.width)) + } + + private static var cancelTitle: NSAttributedString { + let title = NSMutableAttributedString( + string: "滑动取消", + attributes: [.foregroundColor: UIColor(hexStr: "#333333")] + ) + title.addAttribute( + .foregroundColor, + value: UIColor(hexStr: "#FF383C"), + range: NSRange(location: 2, length: 2) + ) + return title } } -// MARK: - UIScrollViewDelegate (page control) -extension SOSPracticeView: UIScrollViewDelegate { - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - guard scrollView == self.scrollView else { return } - let page = Int(scrollView.contentOffset.x / scrollView.bounds.width) - pageControl.currentPage = page +private final class SOSPracticeModeBadge: UIView { + + private let gradientLayer = CAGradientLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + gradientLayer.colors = [ + UIColor(hexStr: "#DDF4FF").cgColor, + UIColor(hexStr: "#52C5F4").cgColor, + UIColor(hexStr: "#DDF4FF").cgColor + ] + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + layer.addSublayer(gradientLayer) + + let label = UILabel() + label.text = "练习模式" + label.textColor = .white + label.font = .systemFont(ofSize: 16, weight: .semibold) + label.textAlignment = .center + addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: topAnchor), + label.leadingAnchor.constraint(equalTo: leadingAnchor), + label.trailingAnchor.constraint(equalTo: trailingAnchor), + label.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds } } diff --git a/QuickLocation/Section/Home/SOS/SOSReceiveAlertView.swift b/QuickLocation/Section/Home/SOS/SOSReceiveAlertView.swift new file mode 100644 index 00000000..37027f08 --- /dev/null +++ b/QuickLocation/Section/Home/SOS/SOSReceiveAlertView.swift @@ -0,0 +1,357 @@ +// +// SOSReceiveAlertView.swift +// QuickLocation +// + +import UIKit + +/// Full-screen SOS alert shown above the currently visible application page. +final class SOSReceiveAlertView: UIView { + + var onAcknowledged: (() -> Void)? + + private let gradientLayer = CAGradientLayer() + private let primaryAvatarView = UIImageView() + private let primaryAvatarRingView = UIView() + private let alarmIconView = UIImageView(image: UIImage(named: "SOS/sos_receive_alarm")) + private let nameLabel = UILabel() + private let messageLabel = UILabel() + private let titleImageView = UIImageView(image: UIImage(named: "SOS/sos_receive_title")) + private let acknowledgeButton = UIButton(type: .custom) + private let extraBadge = UILabel() + private let rippleLayers = [CAShapeLayer(), CAShapeLayer()] + + private var primaryMemberID = "" + private var secondaryMembers: [GroupMemberModel] = [] + private var secondaryAvatarViews: [String: UIImageView] = [:] + private var hasStartedAnimations = false + + private static let rippleKey = "sos.receive.ripple" + private static let backgroundFlashKey = "sos.receive.background.flash" + private static let alarmFlashKey = "sos.receive.alarm.flash" + private static let secondaryOffsets: [CGPoint] = [ + CGPoint(x: -112, y: -60), + CGPoint(x: 118, y: 8), + CGPoint(x: -124, y: 26), + CGPoint(x: 108, y: 96), + CGPoint(x: -105, y: 100), + CGPoint(x: -60, y: -116), + CGPoint(x: 55, y: -120), + CGPoint(x: 0, y: 136) + ] + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + gradientLayer.frame = bounds + + let side = min(bounds.width, 414) + let avatarSide = min(side * 0.38, 150) + let avatarCenter = CGPoint( + x: bounds.midX, + y: min(bounds.height * 0.35, 292) + ) + primaryAvatarRingView.bounds = CGRect(x: 0, y: 0, width: avatarSide, height: avatarSide) + primaryAvatarRingView.center = avatarCenter + primaryAvatarRingView.layer.cornerRadius = avatarSide / 2 + primaryAvatarView.frame = primaryAvatarRingView.bounds.insetBy(dx: 7, dy: 7) + primaryAvatarView.layer.cornerRadius = primaryAvatarView.bounds.height / 2 + + let alarmSide = min(max(avatarSide * 0.46, 58), 72) + alarmIconView.bounds = CGRect(x: 0, y: 0, width: alarmSide, height: alarmSide) + alarmIconView.center = CGPoint( + x: avatarCenter.x + avatarSide * 0.43, + y: avatarCenter.y - avatarSide * 0.47 + ) + + let rippleCenter = avatarCenter + let rippleRadius = avatarSide / 2 + let rippleDiameter = rippleRadius * 2 + rippleLayers.forEach { layer in + layer.bounds = CGRect(x: 0, y: 0, width: rippleDiameter, height: rippleDiameter) + layer.position = rippleCenter + layer.path = UIBezierPath( + arcCenter: CGPoint(x: rippleRadius, y: rippleRadius), + radius: rippleRadius, + startAngle: 0, + endAngle: .pi * 2, + clockwise: true + ).cgPath + } + + let buttonWidth = min(bounds.width - 60, 345) + let buttonHeight: CGFloat = 56 + let bottomInset = max(safeAreaInsets.bottom + 64, 64) + acknowledgeButton.frame = CGRect( + x: bounds.midX - buttonWidth / 2, + y: bounds.height - bottomInset - buttonHeight, + width: buttonWidth, + height: buttonHeight + ) + + let nameTop = min( + avatarCenter.y + avatarSide / 2 + 104, + max(avatarCenter.y + avatarSide / 2 + 48, acknowledgeButton.frame.minY - 190) + ) + nameLabel.frame = CGRect(x: 20, y: nameTop, width: bounds.width - 40, height: 40) + messageLabel.frame = CGRect(x: 20, y: nameLabel.frame.maxY + 10, width: bounds.width - 40, height: 44) + + let titleWidth = min(bounds.width * 0.34, 128) + let titleHeight = titleImageView.image.map { titleWidth * $0.size.height / $0.size.width } ?? 40 + titleImageView.frame = CGRect( + x: bounds.midX - titleWidth / 2, + y: messageLabel.frame.maxY + 16, + width: titleWidth, + height: titleHeight + ) + + let hasOverflow = secondaryMembers.count > Self.secondaryOffsets.count + let visibleCount = hasOverflow ? Self.secondaryOffsets.count - 1 : min(secondaryMembers.count, Self.secondaryOffsets.count) + let extraCount = hasOverflow ? secondaryMembers.count - visibleCount : 0 + for (index, member) in secondaryMembers.enumerated() { + guard let imageView = secondaryAvatarViews[member.user_id] else { continue } + imageView.isHidden = index >= visibleCount + guard index < Self.secondaryOffsets.count else { continue } + let offset = Self.secondaryOffsets[index] + imageView.bounds = CGRect(x: 0, y: 0, width: 44, height: 44) + imageView.center = CGPoint(x: avatarCenter.x + offset.x, y: avatarCenter.y + offset.y) + imageView.layer.cornerRadius = 22 + } + + extraBadge.isHidden = extraCount == 0 + if extraCount > 0 { + let offset = Self.secondaryOffsets[Self.secondaryOffsets.count - 1] + extraBadge.bounds = CGRect(x: 0, y: 0, width: 44, height: 44) + extraBadge.center = CGPoint(x: avatarCenter.x + offset.x, y: avatarCenter.y + offset.y) + extraBadge.text = "+\(extraCount)" + } + + bringSubviewToFront(primaryAvatarRingView) + bringSubviewToFront(alarmIconView) + bringSubviewToFront(nameLabel) + bringSubviewToFront(messageLabel) + bringSubviewToFront(titleImageView) + bringSubviewToFront(acknowledgeButton) + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { + stopAnimations() + } else if !isHidden { + startAnimationsIfNeeded() + } + } + + func show(primary member: GroupMemberModel) { + primaryMemberID = member.user_id + secondaryMembers.removeAll() + secondaryAvatarViews.values.forEach { $0.removeFromSuperview() } + secondaryAvatarViews.removeAll() + extraBadge.isHidden = true + + let image = member.userIcon.size == .zero + ? UIImage(named: "Common/default_avatar") + : member.userIcon + primaryAvatarView.image = image + nameLabel.text = member.nick_name.isEmpty ? "圈子成员" : member.nick_name + isHidden = false + setNeedsLayout() + layoutIfNeeded() + startAnimationsIfNeeded() + } + + func appendSecondary(_ member: GroupMemberModel) { + guard !member.user_id.isEmpty, + member.user_id != primaryMemberID, + !secondaryMembers.contains(where: { $0.user_id == member.user_id }) else { return } + + secondaryMembers.append(member) + let imageView = makeSecondaryAvatar(member) + secondaryAvatarViews[member.user_id] = imageView + addSubview(imageView) + setNeedsLayout() + } + + func dismiss() { + stopAnimations() + secondaryAvatarViews.values.forEach { $0.removeFromSuperview() } + secondaryAvatarViews.removeAll() + secondaryMembers.removeAll() + primaryMemberID = "" + removeFromSuperview() + } + + private func setupView() { + backgroundColor = .white + isAccessibilityElement = false + accessibilityViewIsModal = true + + gradientLayer.type = .radial + gradientLayer.colors = [ + UIColor.white.cgColor, + UIColor(hexStr: "#FFF4F4").cgColor, + UIColor(hexStr: "#FFB7BA").cgColor + ] + gradientLayer.locations = [0, 0.58, 1] + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 1) + layer.insertSublayer(gradientLayer, at: 0) + + rippleLayers.forEach { + $0.fillColor = UIColor.clear.cgColor + $0.strokeColor = UIColor(hexStr: "#FF7378").cgColor + $0.lineWidth = 1.5 + $0.opacity = 0 + layer.addSublayer($0) + } + + primaryAvatarRingView.backgroundColor = .white + primaryAvatarRingView.layer.cornerRadius = 75 + primaryAvatarRingView.layer.shadowColor = UIColor.black.withAlphaComponent(0.14).cgColor + primaryAvatarRingView.layer.shadowOpacity = 1 + primaryAvatarRingView.layer.shadowRadius = 8 + primaryAvatarRingView.layer.shadowOffset = CGSize(width: 0, height: 3) + addSubview(primaryAvatarRingView) + + primaryAvatarView.contentMode = .scaleAspectFill + primaryAvatarView.clipsToBounds = true + primaryAvatarView.layer.masksToBounds = true + primaryAvatarRingView.addSubview(primaryAvatarView) + + alarmIconView.contentMode = .scaleAspectFit + addSubview(alarmIconView) + + nameLabel.textColor = UIColor(hexStr: "#273246") + nameLabel.font = .systemFont(ofSize: 28, weight: .regular) + nameLabel.textAlignment = .center + addSubview(nameLabel) + + messageLabel.text = "正在向您紧急求助" + messageLabel.textColor = UIColor(hexStr: "#273246") + messageLabel.font = .systemFont(ofSize: 32, weight: .regular) + messageLabel.textAlignment = .center + messageLabel.adjustsFontSizeToFitWidth = true + messageLabel.minimumScaleFactor = 0.72 + addSubview(messageLabel) + + titleImageView.contentMode = .scaleAspectFit + addSubview(titleImageView) + + extraBadge.backgroundColor = UIColor(hexStr: "#FF5A5F") + extraBadge.textColor = .white + extraBadge.font = .systemFont(ofSize: 13, weight: .semibold) + extraBadge.textAlignment = .center + extraBadge.layer.cornerRadius = 22 + extraBadge.layer.borderWidth = 3 + extraBadge.layer.borderColor = UIColor.white.cgColor + extraBadge.clipsToBounds = true + extraBadge.isHidden = true + addSubview(extraBadge) + + acknowledgeButton.backgroundColor = UIColor(hexStr: "#FF5050") + acknowledgeButton.cornerRadius = 20 + acknowledgeButton.setTitle("我知道了", for: .normal) + acknowledgeButton.setTitleColor(.white, for: .normal) + acknowledgeButton.titleLabel?.font = FontManager.boboBold(18) + acknowledgeButton.addTarget(self, action: #selector(handleAcknowledged), for: .touchUpInside) + acknowledgeButton.accessibilityLabel = "我知道了" + addSubview(acknowledgeButton) + } + + private func makeSecondaryAvatar(_ member: GroupMemberModel) -> UIImageView { + let imageView = UIImageView() + imageView.image = member.userIcon.size == .zero + ? UIImage(named: "Common/default_avatar") + : member.userIcon + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.borderWidth = 4 + imageView.layer.borderColor = UIColor.white.cgColor + imageView.layer.shadowColor = UIColor.black.withAlphaComponent(0.18).cgColor + imageView.layer.shadowOpacity = 1 + imageView.layer.shadowRadius = 4 + imageView.layer.shadowOffset = CGSize(width: 0, height: 2) + return imageView + } + + private func startAnimationsIfNeeded() { + guard !hasStartedAnimations, + window != nil, + !UIAccessibility.isReduceMotionEnabled else { return } + hasStartedAnimations = true + + let flash = CAKeyframeAnimation(keyPath: "opacity") + flash.values = [0.42, 1.0, 0.42] + flash.keyTimes = [0, 0.5, 1] + flash.duration = 1.15 + flash.repeatCount = .infinity + flash.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + flash.isRemovedOnCompletion = false + gradientLayer.add(flash, forKey: Self.backgroundFlashKey) + + let alarmOpacity = CAKeyframeAnimation(keyPath: "opacity") + alarmOpacity.values = [1.0, 0.25, 1.0] + alarmOpacity.keyTimes = [0, 0.5, 1] + alarmOpacity.duration = 0.7 + alarmOpacity.repeatCount = .infinity + alarmOpacity.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + + let alarmScale = CAKeyframeAnimation(keyPath: "transform.scale") + alarmScale.values = [1.0, 1.08, 1.0] + alarmScale.keyTimes = [0, 0.5, 1] + alarmScale.duration = 0.7 + alarmScale.repeatCount = .infinity + alarmScale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + + let alarmGroup = CAAnimationGroup() + alarmGroup.animations = [alarmOpacity, alarmScale] + alarmGroup.duration = 0.7 + alarmGroup.repeatCount = .infinity + alarmIconView.layer.add(alarmGroup, forKey: Self.alarmFlashKey) + + let travelDuration = 1.35 + let staggerDuration = travelDuration * 0.85 + let cycleDuration = staggerDuration * Double(rippleLayers.count) + let travelProgress = NSNumber(value: travelDuration / cycleDuration) + let baseTime = CACurrentMediaTime() + for (index, rippleLayer) in rippleLayers.enumerated() { + let scale = CAKeyframeAnimation(keyPath: "transform.scale") + scale.values = [1.0, 2.1, 2.1] + scale.keyTimes = [0, travelProgress, 1] + + let opacity = CAKeyframeAnimation(keyPath: "opacity") + opacity.values = [0.58, 0, 0] + opacity.keyTimes = [0, travelProgress, 1] + + let group = CAAnimationGroup() + group.animations = [scale, opacity] + group.duration = cycleDuration + group.beginTime = baseTime + Double(index) * staggerDuration + group.repeatCount = .infinity + group.isRemovedOnCompletion = false + rippleLayer.add(group, forKey: Self.rippleKey) + } + } + + private func stopAnimations() { + hasStartedAnimations = false + gradientLayer.removeAnimation(forKey: Self.backgroundFlashKey) + gradientLayer.opacity = 1 + alarmIconView.layer.removeAnimation(forKey: Self.alarmFlashKey) + rippleLayers.forEach { $0.removeAnimation(forKey: Self.rippleKey) } + } + + @objc private func handleAcknowledged() { + onAcknowledged?() + } +} diff --git a/QuickLocation/Section/Home/SOS/SOSView.swift b/QuickLocation/Section/Home/SOS/SOSView.swift index 73b1c99d..fbd78b0f 100644 --- a/QuickLocation/Section/Home/SOS/SOSView.swift +++ b/QuickLocation/Section/Home/SOS/SOSView.swift @@ -2,299 +2,693 @@ // SOSView.swift // QuickLocation // -// Created by 八条 on 2026/6/18. -// import UIKit -import RxSwift -import RxCocoa -import RxGesture -import Lottie import SwiftyUserDefaults -class SOSView: UIView { +enum SOSPageState { + case idle + case countdown + case sending + case active +} - var disposeBag = DisposeBag() - - var sosPracticeView: SOSPracticeView = SOSPracticeView() - - var didSendSOS: ((Bool) -> Void)? - - var countDownFinish: Bool = false - - private func setupRx() { - exclamationLottieView.rx.tapGesture.subscribe(onNext: { _ in - UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseInOut], animations: { - self.countDownView.alpha = 1 - }, completion: { _ in - self.countDownLottieView.play { completed in - guard completed else { return } - if let path = Bundle.main.path(forResource: "red-exclamation", ofType: "json") { - self.countDownLottieView.animation = LottieAnimation.filepath(path) - self.countDownLottieView.loopMode = .loop - self.countDownLottieView.play() - self.countDownTipsLab.text = "已将你的SOS和位置发送到你的圈子和紧急联系人。\n\n您的 SOS 将发送给5个人" - self.countDownFinish = true - if let didSendSOS = self.didSendSOS { - didSendSOS(true) - } - } - } - }) - }).disposed(by: disposeBag) - - sliderIcon.rx.panGesture() - .subscribe(onNext: { [weak self] gesture in - guard let self = self else { return } - let translation = gesture.translation(in: self.sliderView) - let maxX = self.sliderView.bounds.width - self.sliderIcon.frame.width +final class SOSView: UIView { - switch gesture.state { - case .began, .changed: - var newX = self.sliderIcon.frame.minX + translation.x - newX = max(0, min(maxX, newX)) - self.sliderIcon.frame.origin.x = newX - gesture.setTranslation(.zero, in: self.sliderView) - case .ended: - if self.sliderIcon.frame.minX >= maxX - 5 { - if countDownFinish { - if let didSendSOS = self.didSendSOS { - didSendSOS(false) - } - } - else { - self.countDownView.alpha = 0 - self.countDownLottieView.stop() - } - } - UIView.animate(withDuration: 0.3) { - self.sliderIcon.frame.origin.x = 3 - } + var onCountdownCompleted: (() -> Void)? + var onCancelActive: (() -> Void)? + var onManageContacts: (() -> Void)? + + private(set) var state: SOSPageState = .idle + let sosPracticeView = SOSPracticeView() + + private let activeBackgroundView = SOSActiveBackgroundView() + private let navView = BaseNavigationView(title: "紧急求救") + private let infoCard = UIView() + private let idleView = UIView() + private let countdownView = UIView() + private let activeView = UIView() + private let idlePulseView = SOSPulseButtonView() + private let activePulseView = SOSPulseButtonView() + private let countdownRingView = SOSCountdownRingView() + private let countdownTitleLabel = UILabel() + private let countdownBodyLabel = UILabel() + private let countdownSlider = SOSSlideCancelView() + private let activeSlider = SOSSlideCancelView() + private let contactStack = UIStackView() + private let contactScrollView = UIScrollView() + private let contactPanel = UIView() + private let emptyContactLabel = UILabel() - default: - break - } - }) - .disposed(by: disposeBag) - - // 添加紧急联系人 - addContactView.rx.tapGesture.subscribe(onNext: { _ in - AppRouter.push(Route.emergencyContact) - }).disposed(by: disposeBag) - } - - private func setupUI() { - addSubview(exclamationLottieView) - addSubview(messageLab) - addSubview(tipsLab) - addSubview(bottomTipsLab) - addSubview(addContactView) - addSubview(sosPracticeView) - addSubview(countDownView) - addSubview(navBgView) - addSubview(navView) - - navBgView.layoutChain - .edges(excludingEdge: .bottom) - .heightToWidth(160/375) - - navView.layoutChain - .edges(excludingEdge: .bottom) - .height(kNaviHeight) - - sosPracticeView.layoutChain - .topToBottomOfView(navView) - .edges(excludingEdge: .top) - - exclamationLottieView.layoutChain - .topToBottomOfView(navView, offset: 40) - .edgesHorzontal(28) - .heightToWidth(1) - - messageLab.layoutChain - .topToBottomOfView(exclamationLottieView, offset: 19) - .centerX() - - tipsLab.layoutChain - .topToBottomOfView(messageLab, offset: 10) - .edgesHorzontal(23) - - addContactView.layoutChain - .topToBottomOfView(tipsLab, offset: 30) - .centerX() - - countDownView.layoutChain - .topToBottomOfView(navView) - .edges(excludingEdge: .top) - - bottomTipsLab.layoutChain - .centerX() - .bottom(kSafeBottomMargin + 40) - } - - lazy var navBgView: UIImageView = { - let iv = UIImageView() - iv.image = UIImage(named: "Common/navBar_bg_2") - iv.contentMode = .scaleAspectFill - return iv - }() - - lazy var navView: BaseNavigationView = { - let nav = BaseNavigationView(title: "SOS") - return nav - }() - - lazy var exclamationLottieView: LottieAnimationView = { - let view = LottieAnimationView(name: "yellow-exclamation") - view.loopMode = .loop - return view - }() - - lazy var messageLab: UILabel = { - let label = UILabel() - label.text = "当您感到紧张或不安全时,请按住此按钮。" - label.font = .systemFont(ofSize: 16, weight: .medium) - label.textColor = UIColor(hexStr: "#333333") - return label - }() - - lazy var tipsLab: UILabel = { - let label = UILabel() - label.text = "提示:若你在气泡当中时,发送SOS将会自动从气泡中弹出。" - label.font = .systemFont(ofSize: 14, weight: .regular) - label.textColor = UIColor(hexStr: "#999999") - label.numberOfLines = 0 - return label - }() - - lazy var addContactView: UIView = { - let view = UIView() - view.backgroundColor = .clear - - let icon = UIImageView(image: UIImage(named: "SOS/add")) - view.addSubview(icon) - icon.layoutChain - .left() - .edgesVertical() - .height(30) - .width(30) - - let label = UILabel() - label.text = "添加紧急联系人" - label.font = .systemFont(ofSize: 14, weight: .medium) - view.addSubview(label) - label.layoutChain - .leftToRightOfView(icon, offset: 10) - .right() - .centerY() - - return view - }() - - // 倒计时 - lazy var countDownView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.alpha = 0 - - view.addSubview(countDownLottieView) - countDownLottieView.layoutChain - .top(40) - .edgesHorzontal(27) - .heightToWidth(1) - - let titleLab = UILabel() - titleLab.text = "滑动取消" - titleLab.font = .systemFont(ofSize: 26, weight: .semibold) - view.addSubview(titleLab) - titleLab.layoutChain - .topToBottomOfView(countDownLottieView, offset: 15) - .centerX() - - view.addSubview(countDownTipsLab) - countDownTipsLab.layoutChain - .topToBottomOfView(titleLab, offset: 8) - .edgesHorzontal(38) - - view.addSubview(sliderView) - sliderView.layoutChain - .edgesHorzontal(30) - .bottom(kSafeBottomMargin + 30) - .height(50) - - return view - }() - - lazy var countDownLottieView: LottieAnimationView = { - let view = LottieAnimationView(name: "10-second-timer") - view.loopMode = .playOnce - return view - }() - - lazy var countDownTipsLab: UILabel = { - let tipsLab = UILabel() - tipsLab.text = "10秒后,会将你的SOS和位置发送到你的圈子和紧急联系人。" - tipsLab.font = .systemFont(ofSize: 16, weight: .medium) - tipsLab.textAlignment = .center - tipsLab.numberOfLines = 0 - - return tipsLab - }() - - lazy var sliderView: UIView = { - let view = UIView() - view.backgroundColor = .clear - view.cornerRadius = 25 - - let imgView = UIImageView(image: UIImage(named: "Common/button_bg_2")) - imgView.contentMode = .scaleAspectFill - view.addSubview(imgView) - imgView.layoutChain.edges() - - let label = UILabel() - label.text = "滑动以取消SOS" - label.textColor = .white - label.font = .systemFont(ofSize: 16, weight: .medium) - view.addSubview(label) - label.layoutChain.centerX().centerY() - - view.addSubview(sliderIcon) - - return view - }() - - lazy var sliderIcon: UIImageView = { - let view = UIImageView(frame: CGRectMake(3, 2, 46, 46)) - view.image = UIImage(named: "SOS/slider") - view.isUserInteractionEnabled = true - return view - }() - - lazy var bottomTipsLab: UILabel = { - let label = UILabel() - label.textColor = UIColor(hexStr: "#333333") - let text = "轻点感叹号发送SOS" - let attr = NSMutableAttributedString(string: text) - attr.addAttribute(.font, value: UIFont.systemFont(ofSize: 20, weight: .semibold), range: NSRange(location: 0, length: text.count)) - attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#FF383C"), range: NSRange(location: "轻点感叹号发送".count, length: "SOS".count)) - label.attributedText = attr - return label - }() - override init(frame: CGRect) { - super.init(frame: .zero) - backgroundColor = .white + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#F9F9F9") setupUI() - setupRx() - - exclamationLottieView.play() - - let isHidden = Defaults[\.sosIsPracticeList].first(where: { $0 == AppContextManager.shared.userId }) != nil - sosPracticeView.isHidden = isHidden + setupActions() + configureContacts([]) + setState(.idle) + + let hasCompletedPractice = Defaults[\.sosIsPracticeList].contains(AppContextManager.shared.userId) + sosPracticeView.isHidden = hasCompletedPractice } - required init?(coder aDecoder: NSCoder) { + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + func setState(_ state: SOSPageState) { + self.state = state + idleView.isHidden = state != .idle + countdownView.isHidden = state != .countdown && state != .sending + activeView.isHidden = state != .active + activeBackgroundView.isHidden = state != .active + + if state == .active { + activeBackgroundView.startFlashing() + } else { + activeBackgroundView.stopFlashing() + } + + switch state { + case .idle: + countdownRingView.stop() + countdownSlider.configure(style: .countdown) + countdownSlider.setInteractionEnabled(true) + idlePulseView.setShowsHand(true) + idlePulseView.setAnimationEnabled(true) + idlePulseView.isUserInteractionEnabled = true + activePulseView.setAnimationEnabled(false) + case .countdown: + idlePulseView.setAnimationEnabled(false) + activePulseView.setAnimationEnabled(false) + countdownTitleLabel.attributedText = Self.cancelTitle + countdownBodyLabel.text = "10秒后,会将你的SOS和位置发送到你的圈子和紧急联系人。" + countdownSlider.configure(style: .countdown) + countdownSlider.setInteractionEnabled(true) + countdownRingView.start(duration: 10) + case .sending: + countdownRingView.stop(reset: false) + countdownTitleLabel.text = "正在发送" + countdownTitleLabel.textColor = UIColor(hexStr: "#273246") + countdownBodyLabel.text = "正在发送你的SOS和位置信息,请稍候。" + countdownSlider.setInteractionEnabled(false) + case .active: + countdownRingView.stop() + activeSlider.configure(style: .active) + activeSlider.setInteractionEnabled(true) + idlePulseView.setAnimationEnabled(false) + activePulseView.setShowsHand(false) + activePulseView.setAnimationEnabled(true) + activePulseView.isUserInteractionEnabled = false + } + } + + func setActiveCancelEnabled(_ enabled: Bool) { + activeSlider.setInteractionEnabled(enabled) + if enabled { + activeSlider.reset(animated: false) + } + } + + func configureContacts(_ contacts: [EmergencyContactModel]) { + contactStack.arrangedSubviews.forEach { + contactStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + + emptyContactLabel.isHidden = !contacts.isEmpty + contactScrollView.isHidden = contacts.isEmpty + guard !contacts.isEmpty else { return } + + contacts.forEach { contact in + let card = SOSEmergencyContactCard() + card.configure(contact) + card.widthAnchor.constraint(equalToConstant: 100).isActive = true + contactStack.addArrangedSubview(card) + } + } + + func stopTransientActivities() { + guard state == .countdown else { return } + countdownRingView.stop() + setState(.idle) + } + + private func setupUI() { + addSubview(activeBackgroundView) + activeBackgroundView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + activeBackgroundView.topAnchor.constraint(equalTo: topAnchor), + activeBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + activeBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + activeBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + setupNavigation() + setupInfoCard() + + addSubview(idleView) + addSubview(countdownView) + addSubview(activeView) + [idleView, countdownView, activeView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + $0.topAnchor.constraint(equalTo: infoCard.bottomAnchor, constant: 6), + $0.leadingAnchor.constraint(equalTo: leadingAnchor), + $0.trailingAnchor.constraint(equalTo: trailingAnchor), + $0.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + setupIdleView() + setupCountdownView() + setupActiveView() + + addSubview(sosPracticeView) + sosPracticeView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + sosPracticeView.topAnchor.constraint(equalTo: navView.bottomAnchor), + sosPracticeView.leadingAnchor.constraint(equalTo: leadingAnchor), + sosPracticeView.trailingAnchor.constraint(equalTo: trailingAnchor), + sosPracticeView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + private func setupNavigation() { + addSubview(navView) + navView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + navView.topAnchor.constraint(equalTo: topAnchor), + navView.leadingAnchor.constraint(equalTo: leadingAnchor), + navView.trailingAnchor.constraint(equalTo: trailingAnchor), + navView.heightAnchor.constraint(equalToConstant: kNaviHeight) + ]) + + navView.titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + let navigationOffset = CGAffineTransform(translationX: 0, y: -7) + navView.titleLabel.transform = navigationOffset + navView.backBtn.transform = navigationOffset + let moreButton = UIButton(type: .custom) + moreButton.setImage(UIImage(named: "SOS/sos_nav_more"), for: .normal) + moreButton.widthAnchor.constraint(equalToConstant: 24).isActive = true + moreButton.heightAnchor.constraint(equalToConstant: 24).isActive = true + moreButton.transform = CGAffineTransform(a: 1.5, b: 0, c: 0, d: 1.5, tx: -7, ty: -7) + moreButton.accessibilityLabel = "更多" + navView.addRightButton(moreButton) + } + + private func setupInfoCard() { + infoCard.backgroundColor = UIColor.white.withAlphaComponent(0.93) + infoCard.layer.cornerRadius = 14 + infoCard.layer.shadowColor = UIColor.black.cgColor + infoCard.layer.shadowOpacity = 0.035 + infoCard.layer.shadowRadius = 10 + infoCard.layer.shadowOffset = CGSize(width: 0, height: 4) + addSubview(infoCard) + infoCard.translatesAutoresizingMaskIntoConstraints = false + + let iconBackground = UIView() + iconBackground.backgroundColor = UIColor(hexStr: "#FFECEE") + iconBackground.layer.cornerRadius = 10 + infoCard.addSubview(iconBackground) + iconBackground.translatesAutoresizingMaskIntoConstraints = false + + let iconView = UIImageView(image: UIImage(named: "Home/sos")) + iconView.contentMode = .scaleAspectFit + iconView.isHidden = true + iconBackground.addSubview(iconView) + iconView.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = UILabel() + titleLabel.text = "遇到紧急情况?一键发送求救信息" + titleLabel.textColor = UIColor(hexStr: "#273246") + titleLabel.font = .systemFont(ofSize: 12, weight: .semibold) + infoCard.addSubview(titleLabel) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + let subtitleLabel = UILabel() + subtitleLabel.text = "我们将向您的紧急联系人发送包含您位置和求救信息的通知" + subtitleLabel.textColor = UIColor(hexStr: "#888888") + subtitleLabel.font = .systemFont(ofSize: 10, weight: .regular) + subtitleLabel.adjustsFontSizeToFitWidth = true + subtitleLabel.minimumScaleFactor = 0.8 + infoCard.addSubview(subtitleLabel) + subtitleLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + infoCard.topAnchor.constraint(equalTo: navView.bottomAnchor, constant: 10), + infoCard.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 15), + infoCard.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -15), + infoCard.heightAnchor.constraint(equalToConstant: 48), + + iconBackground.leadingAnchor.constraint(equalTo: infoCard.leadingAnchor, constant: 10), + iconBackground.centerYAnchor.constraint(equalTo: infoCard.centerYAnchor), + iconBackground.widthAnchor.constraint(equalToConstant: 30), + iconBackground.heightAnchor.constraint(equalToConstant: 30), + + iconView.centerXAnchor.constraint(equalTo: iconBackground.centerXAnchor), + iconView.centerYAnchor.constraint(equalTo: iconBackground.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 24), + iconView.heightAnchor.constraint(equalToConstant: 24), + + titleLabel.topAnchor.constraint(equalTo: infoCard.topAnchor, constant: 7), + titleLabel.leadingAnchor.constraint(equalTo: iconBackground.trailingAnchor, constant: 6), + titleLabel.trailingAnchor.constraint(equalTo: infoCard.trailingAnchor, constant: -8), + + subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 5), + subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + subtitleLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor) + ]) + } + + private func setupIdleView() { + let scrollView = makeStateScrollView(in: idleView) + let contentView = UIView() + scrollView.addSubview(contentView) + pinContentView(contentView, to: scrollView) + + contentView.addSubview(idlePulseView) + idlePulseView.translatesAutoresizingMaskIntoConstraints = false + + setupContactPanel() + contentView.addSubview(contactPanel) + contactPanel.translatesAutoresizingMaskIntoConstraints = false + + let addContactCard = makeAddContactCard() + contentView.addSubview(addContactCard) + addContactCard.translatesAutoresizingMaskIntoConstraints = false + + let pulseWidth = idlePulseView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30) + pulseWidth.priority = .defaultHigh + NSLayoutConstraint.activate([ + idlePulseView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 15), + idlePulseView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + pulseWidth, + idlePulseView.widthAnchor.constraint(lessThanOrEqualToConstant: 345), + idlePulseView.heightAnchor.constraint(equalTo: idlePulseView.widthAnchor), + + contactPanel.topAnchor.constraint(equalTo: idlePulseView.bottomAnchor, constant: 18), + contactPanel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15), + contactPanel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15), + contactPanel.heightAnchor.constraint(equalToConstant: 104), + + addContactCard.topAnchor.constraint(equalTo: contactPanel.bottomAnchor, constant: 17), + addContactCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15), + addContactCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15), + addContactCard.heightAnchor.constraint(equalToConstant: 88), + addContactCard.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20) + ]) + } + + private func setupContactPanel() { + contactPanel.backgroundColor = UIColor.white.withAlphaComponent(0.95) + contactPanel.layer.cornerRadius = 14 + contactPanel.layer.shadowColor = UIColor.black.cgColor + contactPanel.layer.shadowOpacity = 0.025 + contactPanel.layer.shadowRadius = 8 + contactPanel.layer.shadowOffset = CGSize(width: 0, height: 3) + + let locationIcon = UIImageView(image: UIImage(named: "SOS/sos_contact_location")) + locationIcon.contentMode = .scaleAspectFit + contactPanel.addSubview(locationIcon) + locationIcon.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = UILabel() + titleLabel.text = "紧急联系人" + titleLabel.textColor = UIColor(hexStr: "#273246") + titleLabel.font = .systemFont(ofSize: 14, weight: .semibold) + contactPanel.addSubview(titleLabel) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + let arrow = UIImageView(image: UIImage(named: "SOS/sos_arrow_right")) + arrow.contentMode = .scaleAspectFit + contactPanel.addSubview(arrow) + arrow.translatesAutoresizingMaskIntoConstraints = false + + contactScrollView.showsHorizontalScrollIndicator = false + contactScrollView.alwaysBounceHorizontal = true + contactPanel.addSubview(contactScrollView) + contactScrollView.translatesAutoresizingMaskIntoConstraints = false + + contactStack.axis = .horizontal + contactStack.alignment = .fill + contactStack.distribution = .fill + contactStack.spacing = 10 + contactScrollView.addSubview(contactStack) + contactStack.translatesAutoresizingMaskIntoConstraints = false + + emptyContactLabel.text = "暂无紧急联系人" + emptyContactLabel.textColor = UIColor(hexStr: "#AAAAAA") + emptyContactLabel.font = .systemFont(ofSize: 13) + contactPanel.addSubview(emptyContactLabel) + emptyContactLabel.translatesAutoresizingMaskIntoConstraints = false + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleManageContacts)) + contactPanel.addGestureRecognizer(tap) + + NSLayoutConstraint.activate([ + locationIcon.topAnchor.constraint(equalTo: contactPanel.topAnchor, constant: 13), + locationIcon.leadingAnchor.constraint(equalTo: contactPanel.leadingAnchor, constant: 12), + locationIcon.widthAnchor.constraint(equalToConstant: 16), + locationIcon.heightAnchor.constraint(equalToConstant: 16), + + titleLabel.centerYAnchor.constraint(equalTo: locationIcon.centerYAnchor), + titleLabel.leadingAnchor.constraint(equalTo: locationIcon.trailingAnchor, constant: 8), + + arrow.centerYAnchor.constraint(equalTo: locationIcon.centerYAnchor), + arrow.trailingAnchor.constraint(equalTo: contactPanel.trailingAnchor, constant: -12), + arrow.widthAnchor.constraint(equalToConstant: 14), + arrow.heightAnchor.constraint(equalToConstant: 14), + + contactScrollView.topAnchor.constraint(equalTo: locationIcon.bottomAnchor, constant: 8), + contactScrollView.leadingAnchor.constraint(equalTo: contactPanel.leadingAnchor, constant: 12), + contactScrollView.trailingAnchor.constraint(equalTo: contactPanel.trailingAnchor, constant: -12), + contactScrollView.bottomAnchor.constraint(equalTo: contactPanel.bottomAnchor, constant: -9), + + contactStack.topAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.topAnchor), + contactStack.leadingAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.leadingAnchor), + contactStack.trailingAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.trailingAnchor), + contactStack.bottomAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.bottomAnchor), + contactStack.heightAnchor.constraint(equalTo: contactScrollView.frameLayoutGuide.heightAnchor), + + emptyContactLabel.centerXAnchor.constraint(equalTo: contactPanel.centerXAnchor), + emptyContactLabel.centerYAnchor.constraint(equalTo: contactScrollView.centerYAnchor) + ]) + } + + private func makeAddContactCard() -> UIControl { + let control = UIControl() + control.backgroundColor = UIColor.white.withAlphaComponent(0.95) + control.layer.cornerRadius = 14 + control.layer.shadowColor = UIColor.black.cgColor + control.layer.shadowOpacity = 0.025 + control.layer.shadowRadius = 8 + control.layer.shadowOffset = CGSize(width: 0, height: 3) + control.addTarget(self, action: #selector(handleManageContacts), for: .touchUpInside) + + let iconView = UIImageView(image: UIImage(named: "SOS/add")) + iconView.contentMode = .scaleAspectFit + iconView.translatesAutoresizingMaskIntoConstraints = false + + let label = UILabel() + label.text = "添加紧急联系人" + label.textColor = UIColor(hexStr: "#999999") + label.font = .systemFont(ofSize: 14, weight: .regular) + + let contentStack = UIStackView(arrangedSubviews: [iconView, label]) + contentStack.axis = .horizontal + contentStack.alignment = .center + contentStack.spacing = 8 + control.addSubview(contentStack) + contentStack.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + contentStack.centerXAnchor.constraint(equalTo: control.centerXAnchor), + contentStack.centerYAnchor.constraint(equalTo: control.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 30), + iconView.heightAnchor.constraint(equalToConstant: 30) + ]) + return control + } + + private func setupCountdownView() { + let scrollView = makeStateScrollView(in: countdownView) + let contentView = UIView() + scrollView.addSubview(contentView) + pinContentView(contentView, to: scrollView, minimumHeight: true) + + contentView.addSubview(countdownRingView) + countdownRingView.translatesAutoresizingMaskIntoConstraints = false + + countdownTitleLabel.attributedText = Self.cancelTitle + countdownTitleLabel.textAlignment = .center + countdownTitleLabel.font = .systemFont(ofSize: 22, weight: .regular) + contentView.addSubview(countdownTitleLabel) + countdownTitleLabel.translatesAutoresizingMaskIntoConstraints = false + + countdownBodyLabel.text = "10秒后,会将你的SOS和位置发送到你的圈子和紧急联系人。" + countdownBodyLabel.textColor = UIColor(hexStr: "#273246") + countdownBodyLabel.font = .systemFont(ofSize: 15, weight: .regular) + countdownBodyLabel.textAlignment = .center + countdownBodyLabel.numberOfLines = 0 + contentView.addSubview(countdownBodyLabel) + countdownBodyLabel.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(countdownSlider) + countdownSlider.translatesAutoresizingMaskIntoConstraints = false + + let ringWidth = countdownRingView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30) + ringWidth.priority = .defaultHigh + let sliderTop = countdownSlider.topAnchor.constraint(greaterThanOrEqualTo: countdownBodyLabel.bottomAnchor, constant: 28) + NSLayoutConstraint.activate([ + countdownRingView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 15), + countdownRingView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + ringWidth, + countdownRingView.widthAnchor.constraint(lessThanOrEqualToConstant: 345), + countdownRingView.heightAnchor.constraint(equalTo: countdownRingView.widthAnchor), + + countdownTitleLabel.topAnchor.constraint(equalTo: countdownRingView.bottomAnchor, constant: 8), + countdownTitleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + + countdownBodyLabel.topAnchor.constraint(equalTo: countdownTitleLabel.bottomAnchor, constant: 24), + countdownBodyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 40), + countdownBodyLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -40), + + sliderTop, + countdownSlider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 30), + countdownSlider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -30), + countdownSlider.heightAnchor.constraint(equalToConstant: 56), + countdownSlider.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -(kSafeBottomMargin + 12)) + ]) + } + + private func setupActiveView() { + let scrollView = makeStateScrollView(in: activeView) + let contentView = UIView() + scrollView.addSubview(contentView) + pinContentView(contentView, to: scrollView, minimumHeight: true) + + contentView.addSubview(activePulseView) + activePulseView.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = UILabel() + titleLabel.attributedText = Self.cancelTitle + titleLabel.font = .systemFont(ofSize: 22, weight: .regular) + titleLabel.textAlignment = .center + contentView.addSubview(titleLabel) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(activeSlider) + activeSlider.translatesAutoresizingMaskIntoConstraints = false + + let pulseWidth = activePulseView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30) + pulseWidth.priority = .defaultHigh + NSLayoutConstraint.activate([ + activePulseView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 15), + activePulseView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + pulseWidth, + activePulseView.widthAnchor.constraint(lessThanOrEqualToConstant: 345), + activePulseView.heightAnchor.constraint(equalTo: activePulseView.widthAnchor), + + titleLabel.topAnchor.constraint(equalTo: activePulseView.bottomAnchor, constant: 8), + titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + + activeSlider.topAnchor.constraint(greaterThanOrEqualTo: titleLabel.bottomAnchor, constant: 40), + activeSlider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 30), + activeSlider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -30), + activeSlider.heightAnchor.constraint(equalToConstant: 56), + activeSlider.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -(kSafeBottomMargin + 12)) + ]) + } + + private func setupActions() { + idlePulseView.onTap = { [weak self] in + guard let self, self.state == .idle else { return } + self.setState(.countdown) + } + countdownRingView.onCompleted = { [weak self] in + guard let self, self.state == .countdown else { return } + self.setState(.sending) + self.onCountdownCompleted?() + } + countdownSlider.onCompleted = { [weak self] in + guard let self, self.state == .countdown else { return } + self.countdownRingView.stop() + self.setState(.idle) + } + activeSlider.onCompleted = { [weak self] in + guard let self, self.state == .active else { return } + self.activeSlider.setInteractionEnabled(false) + self.onCancelActive?() + } + } + + private func makeStateScrollView(in container: UIView) -> UIScrollView { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.alwaysBounceVertical = false + container.addSubview(scrollView) + scrollView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: container.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + return scrollView + } + + private func pinContentView(_ contentView: UIView, to scrollView: UIScrollView, minimumHeight: Bool = false) { + contentView.translatesAutoresizingMaskIntoConstraints = false + var constraints = [ + contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor) + ] + if minimumHeight { + constraints.append(contentView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor)) + } + NSLayoutConstraint.activate(constraints) + } + + @objc private func handleManageContacts() { + onManageContacts?() + } + + private static var cancelTitle: NSAttributedString { + let text = NSMutableAttributedString( + string: "滑动取消", + attributes: [.foregroundColor: UIColor(hexStr: "#333333")] + ) + text.addAttribute( + .foregroundColor, + value: UIColor(hexStr: "#FF383C"), + range: NSRange(location: 2, length: 2) + ) + return text + } +} + +private final class SOSEmergencyContactCard: UIView { + + private let avatarView = UIImageView() + private let phoneBadgeView = UIImageView(image: UIImage(named: "SOS/sos_phone_badge")) + private let nameLabel = UILabel() + private let phoneLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + layer.cornerRadius = 13 + layer.borderWidth = 0.5 + layer.borderColor = UIColor(hexStr: "#E6E6E6").cgColor + + avatarView.image = UIImage(named: "Common/default_avatar") + avatarView.contentMode = .scaleAspectFill + avatarView.clipsToBounds = true + avatarView.layer.cornerRadius = 13 + addSubview(avatarView) + avatarView.translatesAutoresizingMaskIntoConstraints = false + + phoneBadgeView.contentMode = .scaleAspectFit + addSubview(phoneBadgeView) + phoneBadgeView.translatesAutoresizingMaskIntoConstraints = false + + nameLabel.textColor = UIColor(hexStr: "#273246") + nameLabel.font = .systemFont(ofSize: 12, weight: .medium) + nameLabel.lineBreakMode = .byTruncatingTail + addSubview(nameLabel) + nameLabel.translatesAutoresizingMaskIntoConstraints = false + + phoneLabel.textColor = UIColor(hexStr: "#888888") + phoneLabel.font = .systemFont(ofSize: 10, weight: .regular) + phoneLabel.adjustsFontSizeToFitWidth = true + phoneLabel.minimumScaleFactor = 0.72 + addSubview(phoneLabel) + phoneLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + avatarView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 7), + avatarView.centerYAnchor.constraint(equalTo: centerYAnchor), + avatarView.widthAnchor.constraint(equalToConstant: 26), + avatarView.heightAnchor.constraint(equalToConstant: 26), + + phoneBadgeView.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 2), + phoneBadgeView.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor, constant: 2), + phoneBadgeView.widthAnchor.constraint(equalToConstant: 12), + phoneBadgeView.heightAnchor.constraint(equalToConstant: 12), + + nameLabel.topAnchor.constraint(equalTo: topAnchor, constant: 7), + nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 5), + nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), + + phoneLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 2), + phoneLabel.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor), + phoneLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(_ contact: EmergencyContactModel) { + nameLabel.text = contact.name.isEmpty ? "未命名" : contact.name + phoneLabel.text = Self.maskedPhone(contact.phone) + } + + private static func maskedPhone(_ phone: String) -> String { + let digits = phone.filter { $0.isNumber } + guard digits.count >= 7 else { return phone } + return "\(digits.prefix(3))****\(digits.suffix(4))" + } +} + +private final class SOSActiveBackgroundView: UIView { + + private let gradientLayer = CAGradientLayer() + private static let flashAnimationKey = "sos.active.background.flash" + + override init(frame: CGRect) { + super.init(frame: frame) + gradientLayer.type = .radial + gradientLayer.colors = [ + UIColor.white.cgColor, + UIColor(hexStr: "#FFF5F5").cgColor, + UIColor(hexStr: "#FFB7BA").cgColor + ] + gradientLayer.locations = [0, 0.58, 1] + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 1) + layer.addSublayer(gradientLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } + + func startFlashing() { + guard gradientLayer.animation(forKey: Self.flashAnimationKey) == nil else { return } + + let animation = CAKeyframeAnimation(keyPath: "opacity") + animation.values = [0.42, 1.0, 0.42] + animation.keyTimes = [0, 0.5, 1] + animation.duration = 1.15 + animation.repeatCount = .infinity + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animation.isRemovedOnCompletion = false + gradientLayer.add(animation, forKey: Self.flashAnimationKey) + } + + func stopFlashing() { + gradientLayer.removeAnimation(forKey: Self.flashAnimationKey) + gradientLayer.opacity = 1 + } } diff --git a/QuickLocation/Section/Home/SOS/SOSViewController.swift b/QuickLocation/Section/Home/SOS/SOSViewController.swift index 7e9e89df..30c5baa1 100644 --- a/QuickLocation/Section/Home/SOS/SOSViewController.swift +++ b/QuickLocation/Section/Home/SOS/SOSViewController.swift @@ -2,50 +2,81 @@ // SOSViewController.swift // QuickLocation // -// Created by 八条 on 2026/6/18. -// import UIKit import RxSwift -import Lottie -class SOSViewController: BaseViewController { +final class SOSViewController: BaseViewController { + + private var rootView: SOSView! + private var isRequestingSOS = false - fileprivate var rootView: SOSView! - override func loadView() { rootView = SOSView(frame: UIScreen.main.bounds) view = rootView } - + override func viewDidLoad() { super.viewDidLoad() - - // Do any additional setup after loading the view. fd_interactivePopDisabled = true - - rootView.didSendSOS = { status in - self.requestSOS(status) + bindActions() + + rootView.setState(AppContextManager.shared.status == 1 ? .active : .idle) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + requestEmergencyContacts() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + rootView.stopTransientActivities() + } + + private func bindActions() { + rootView.onCountdownCompleted = { [weak self] in + self?.requestSOS(enable: true) } - - guard AppContextManager.shared.status == 1 else { return } - self.rootView.countDownView.alpha = 1 - self.rootView.countDownFinish = true - if let path = Bundle.main.path(forResource: "red-exclamation", ofType: "json") { - self.rootView.countDownLottieView.animation = LottieAnimation.filepath(path) - self.rootView.countDownLottieView.loopMode = .loop - self.rootView.countDownLottieView.play() - self.rootView.countDownTipsLab.text = "已将你的SOS和位置发送到你的圈子和紧急联系人。\n\n您的 SOS 将发送给5个人" + rootView.onCancelActive = { [weak self] in + self?.requestSOS(enable: false) + } + rootView.onManageContacts = { + AppRouter.push(Route.emergencyContact) } } - - private func requestSOS(_ enable: Bool) { - dl.showLoading() - SystemService.sos(enable: enable).subscribe(onNext: { response in - self.dl.show(text: enable ? "发送成功" : "取消成功") { + + private func requestEmergencyContacts() { + UserService.emergencyContactList() + .subscribe(onNext: { [weak self] response in + self?.rootView.configureContacts(response.list) + }, onError: { _ in }) + .disposed(by: disposeBag) + } + + private func requestSOS(enable: Bool) { + guard !isRequestingSOS else { return } + isRequestingSOS = true + dl.showLoading(text: enable ? "正在发送SOS" : "正在取消SOS") + + SystemService.sos(enable: enable) + .subscribe(onNext: { [weak self] _ in + guard let self else { return } + self.isRequestingSOS = false AppContextManager.shared.updateAccountStatus(enable ? 1 : 0) - AppRouter.shared.popOrDismiss() - } - }, onError: { (error) in }).disposed(by: disposeBag) + self.rootView.setState(enable ? .active : .idle) + self.dl.showSuccess(text: enable ? "发送成功" : "取消成功") + }, onError: { [weak self] _ in + guard let self else { return } + self.isRequestingSOS = false + if enable { + self.rootView.setState(.idle) + } else { + self.rootView.setState(.active) + self.rootView.setActiveCancelEnabled(true) + } + self.dl.showError(text: enable ? "SOS发送失败,请重试" : "SOS取消失败,请重试") + }) + .disposed(by: disposeBag) } } diff --git a/QuickLocation/Section/Home/UnlockRequestPopView.swift b/QuickLocation/Section/Home/UnlockRequestPopView.swift new file mode 100644 index 00000000..cd4c4524 --- /dev/null +++ b/QuickLocation/Section/Home/UnlockRequestPopView.swift @@ -0,0 +1,584 @@ +// +// UnlockRequestPopView.swift +// QuickLocation +// + +import UIKit + +struct UnlockRequestDisplayItem { + let member: GroupMemberModel + let lockStartTime: Date +} + +final class UnlockRequestPopView: UIView { + + private static let shared = UnlockRequestPopView( + frame: CGRect(origin: .zero, size: kScreenSize) + ) + + private var requests: [UnlockRequestDisplayItem] = [] + private var onUnlock: ((UnlockRequestDisplayItem) -> Void)? + private var onReject: ((UnlockRequestDisplayItem) -> Void)? + private var timer: Timer? + + static func show( + requests: [UnlockRequestDisplayItem], + onUnlock: ((UnlockRequestDisplayItem) -> Void)? = nil, + onReject: ((UnlockRequestDisplayItem) -> Void)? = nil + ) { + guard !requests.isEmpty, let window = kKeyWindow else { return } + + let popup = UnlockRequestPopView.shared + popup.removeFromSuperview() + popup.stopTimer() + popup.requests = requests + popup.onUnlock = onUnlock + popup.onReject = onReject + popup.collectionView.reloadData() + + window.addSubview(popup) + window.bringSubviewToFront(popup) + popup.layoutIfNeeded() + popup.collectionView.setContentOffset(.zero, animated: false) + popup.startTimer() + + popup.overlayView.alpha = 0 + popup.collectionView.alpha = 0 + popup.collectionView.transform = CGAffineTransform(scaleX: 0.92, y: 0.92) + UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut]) { + popup.overlayView.alpha = 1 + popup.collectionView.alpha = 1 + popup.collectionView.transform = .identity + } + } + + static func dismiss() { + let popup = UnlockRequestPopView.shared + guard popup.superview != nil else { return } + popup.stopTimer() + + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) { + popup.overlayView.alpha = 0 + popup.collectionView.alpha = 0 + popup.collectionView.transform = CGAffineTransform(scaleX: 0.94, y: 0.94) + } completion: { _ in + popup.removeFromSuperview() + popup.collectionView.transform = .identity + popup.requests.removeAll() + popup.onUnlock = nil + popup.onReject = nil + } + } + + private let overlayView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.62) + return view + }() + + private lazy var collectionView: UICollectionView = { + let view = UICollectionView(frame: .zero, collectionViewLayout: UnlockRequestCardLayout()) + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.decelerationRate = .fast + view.clipsToBounds = false + view.dataSource = self + view.delegate = self + view.register(UnlockRequestCardCell.self, forCellWithReuseIdentifier: UnlockRequestCardCell.reuseId) + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + + addSubview(overlayView) + addSubview(collectionView) + + overlayView.layoutChain.edges() + collectionView.layoutChain + .edgesHorzontal() + .centerY() + .height(UnlockRequestCardLayout.itemHeight) + + overlayView.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(tapOutside)) + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func startTimer() { + refreshVisibleTimes() + let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in + self?.refreshVisibleTimes() + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + private func refreshVisibleTimes() { + for case let cell as UnlockRequestCardCell in collectionView.visibleCells { + cell.refreshElapsedTime() + } + } + + private func handleAction(at index: Int, isUnlock: Bool) { + guard requests.indices.contains(index) else { return } + let item = requests[index] + if isUnlock { + onUnlock?(item) + } else { + onReject?(item) + } + + requests.remove(at: index) + guard !requests.isEmpty else { + Self.dismiss() + return + } + + collectionView.performBatchUpdates { + collectionView.deleteItems(at: [IndexPath(item: index, section: 0)]) + } completion: { [weak self] _ in + guard let self else { return } + let next = min(index, self.requests.count - 1) + self.collectionView.scrollToItem( + at: IndexPath(item: next, section: 0), + at: .centeredHorizontally, + animated: true + ) + } + } + + @objc private func tapOutside() { + Self.dismiss() + } +} + +extension UnlockRequestPopView: UICollectionViewDataSource, UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + requests.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: UnlockRequestCardCell.reuseId, + for: indexPath + ) as? UnlockRequestCardCell else { + return UICollectionViewCell() + } + + cell.configure(item: requests[indexPath.item]) + cell.onUnlock = { [weak self, weak cell] in + guard let self, let cell, let path = self.collectionView.indexPath(for: cell) else { return } + self.handleAction(at: path.item, isUnlock: true) + } + cell.onReject = { [weak self, weak cell] in + guard let self, let cell, let path = self.collectionView.indexPath(for: cell) else { return } + self.handleAction(at: path.item, isUnlock: false) + } + return cell + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + collectionView.collectionViewLayout.invalidateLayout() + } +} + +private final class UnlockRequestCardLayout: UICollectionViewFlowLayout { + static let itemWidth: CGFloat = 335 + static let itemHeight: CGFloat = 440 + private let overlap: CGFloat = 35 + + override init() { + super.init() + scrollDirection = .horizontal + minimumInteritemSpacing = 0 + minimumLineSpacing = -overlap + itemSize = CGSize(width: Self.itemWidth, height: Self.itemHeight) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepare() { + super.prepare() + guard let collectionView else { return } + let horizontalInset = max(0, (collectionView.bounds.width - Self.itemWidth) / 2) + sectionInset = UIEdgeInsets(top: 0, left: horizontalInset, bottom: 0, right: horizontalInset) + } + + override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + true + } + + override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + guard let collectionView else { + return super.layoutAttributesForElements(in: rect) + } + + let pageDistance = Self.itemWidth - overlap + let expandedRect = rect.insetBy(dx: -pageDistance * 3, dy: 0) + guard let attributes = super.layoutAttributesForElements(in: expandedRect)? + .compactMap({ $0.copy() as? UICollectionViewLayoutAttributes }) else { + return super.layoutAttributesForElements(in: rect) + } + + let visibleCenterX = collectionView.contentOffset.x + collectionView.bounds.width / 2 + + for attribute in attributes where attribute.representedElementCategory == .cell { + applyStackEffect( + to: attribute, + visibleCenterX: visibleCenterX, + pageDistance: pageDistance + ) + } + return attributes + } + + override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + guard let collectionView, + let attribute = super.layoutAttributesForItem(at: indexPath)? + .copy() as? UICollectionViewLayoutAttributes else { + return super.layoutAttributesForItem(at: indexPath) + } + + applyStackEffect( + to: attribute, + visibleCenterX: collectionView.contentOffset.x + collectionView.bounds.width / 2, + pageDistance: Self.itemWidth - overlap + ) + return attribute + } + + private func applyStackEffect( + to attribute: UICollectionViewLayoutAttributes, + visibleCenterX: CGFloat, + pageDistance: CGFloat + ) { + let progress = (attribute.center.x - visibleCenterX) / pageDistance + + if progress < 0 { + // 已翻过的顶层卡片继续跟手向左移出。 + attribute.center.x = visibleCenterX + progress * pageDistance + attribute.zIndex = 3000 + attribute.alpha = max(0, 1 + progress) + return + } + + guard progress <= 1.001 else { + // 只露出紧邻当前卡片的一张背卡,第三张及之后完全隐藏。 + attribute.alpha = 0 + attribute.zIndex = 0 + return + } + + // 下一张卡片在右下方倾斜露出;滑到当前页时逐渐恢复为正面卡。 + attribute.center.x = visibleCenterX + progress * 13 + attribute.center.y += progress * 10 + let scale = 1 - progress * 0.02 + let rotation = progress * CGFloat.pi / 36 + attribute.transform = CGAffineTransform(rotationAngle: rotation) + .scaledBy(x: scale, y: scale) + attribute.alpha = 1 - progress * 0.5 + attribute.zIndex = 2000 - Int(progress * 100) + } + + override func targetContentOffset( + forProposedContentOffset proposedContentOffset: CGPoint, + withScrollingVelocity velocity: CGPoint + ) -> CGPoint { + guard let collectionView else { return proposedContentOffset } + + let targetRect = CGRect( + x: proposedContentOffset.x, + y: 0, + width: collectionView.bounds.width, + height: collectionView.bounds.height + ) + guard let attributes = super.layoutAttributesForElements(in: targetRect), !attributes.isEmpty else { + return proposedContentOffset + } + + let proposedCenterX = proposedContentOffset.x + collectionView.bounds.width / 2 + let nearest = attributes + .filter { $0.representedElementCategory == .cell } + .min { abs($0.center.x - proposedCenterX) < abs($1.center.x - proposedCenterX) } + + guard let nearest else { return proposedContentOffset } + let offsetX = nearest.center.x - collectionView.bounds.width / 2 + let minX = -collectionView.adjustedContentInset.left + let maxX = collectionView.contentSize.width + - collectionView.bounds.width + + collectionView.adjustedContentInset.right + return CGPoint(x: min(max(offsetX, minX), maxX), y: proposedContentOffset.y) + } +} + +private final class UnlockRequestCardCell: UICollectionViewCell { + static let reuseId = "UnlockRequestCardCell" + + var onUnlock: (() -> Void)? + var onReject: (() -> Void)? + + private var item: UnlockRequestDisplayItem? + private let digitLabels = (0..<4).map { _ in UnlockTimeDigitLabel() } + + private let cardView: UnlockRequestGradientView = { + let view = UnlockRequestGradientView() + view.layer.cornerRadius = 40 + view.clipsToBounds = true + return view + }() + + private let avatarView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.layer.cornerRadius = 15 + view.layer.borderColor = UIColor.white.cgColor + view.layer.borderWidth = 4 + return view + }() + + private let nameContainer: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 10 + return view + }() + + private let nameLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14, weight: .semibold) + label.textColor = UIColor(hexStr: "#293445") + label.lineBreakMode = .byTruncatingTail + return label + }() + + private let lockTitleView: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 8 + return view + }() + + private let lockTitleLabel: UILabel = { + let label = UILabel() + label.text = "已锁:" + label.font = .systemFont(ofSize: 13, weight: .medium) + label.textColor = UIColor(hexStr: "#293445") + label.textAlignment = .center + return label + }() + + private let heroView: UIImageView = { + let view = UIImageView(image: UIImage(named: "Home/unlock_request_popup")) + view.contentMode = .scaleAspectFit + return view + }() + + private let unlockButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("解锁", for: .normal) + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = FontManager.boboBold(18) + button.backgroundColor = UIColor(hexStr: "#293445") + button.layer.cornerRadius = 22 + return button + }() + + private let rejectButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("残忍拒绝", for: .normal) + button.setTitleColor(UIColor(hexStr: "#A8AFBA"), for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + item = nil + onUnlock = nil + onReject = nil + } + + func configure(item: UnlockRequestDisplayItem) { + self.item = item + let image = item.member.userIcon + avatarView.image = image.size == .zero ? UIImage(named: "Common/default_avatar") : image + nameLabel.text = item.member.nick_name.isEmpty ? "圈子成员" : item.member.nick_name + refreshElapsedTime() + } + + func refreshElapsedTime() { + guard let item else { return } + let elapsed = max(0, Int(Date().timeIntervalSince(item.lockStartTime))) + let capped = min(elapsed, 99 * 3600 + 59 * 60) + let hours = capped / 3600 + let minutes = (capped % 3600) / 60 + let digits = String(format: "%02d%02d", hours, minutes) + + for (index, label) in digitLabels.enumerated() { + let stringIndex = digits.index(digits.startIndex, offsetBy: index) + label.text = String(digits[stringIndex]) + } + } + + private func setupUI() { + contentView.addSubview(cardView) + + cardView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + cardView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + cardView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + cardView.widthAnchor.constraint(equalToConstant: 315), + cardView.heightAnchor.constraint(equalToConstant: 420) + ]) + + cardView.addSubview(avatarView) + cardView.addSubview(nameContainer) + nameContainer.addSubview(nameLabel) + cardView.addSubview(lockTitleView) + lockTitleView.addSubview(lockTitleLabel) + + let colonLabel = UILabel() + colonLabel.text = ":" + colonLabel.font = FontManager.boboBold(22) + colonLabel.textColor = UIColor(hexStr: "#293445") + colonLabel.textAlignment = .center + + let timeViews: [UIView] = [ + digitLabels[0], digitLabels[1], colonLabel, digitLabels[2], digitLabels[3] + ] + let timeStack = UIStackView(arrangedSubviews: timeViews) + timeStack.axis = .horizontal + timeStack.alignment = .center + timeStack.spacing = 4 + cardView.addSubview(timeStack) + + cardView.addSubview(heroView) + cardView.addSubview(unlockButton) + cardView.addSubview(rejectButton) + + avatarView.layoutChain + .top(32) + .left(38) + .width(60) + .height(60) + + nameContainer.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + nameContainer.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 32), + nameContainer.leftAnchor.constraint(equalTo: avatarView.rightAnchor, constant: 14), + nameContainer.heightAnchor.constraint(equalToConstant: 34), + nameContainer.rightAnchor.constraint(lessThanOrEqualTo: cardView.rightAnchor, constant: -18) + ]) + nameLabel.layoutChain.edges(all: 8) + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + lockTitleView.layoutChain + .topToBottomOfView(nameContainer, offset: 7) + .leftToView(nameContainer) + .width(48) + .height(30) + lockTitleLabel.layoutChain.edges() + + timeStack.layoutChain + .leftToRightOfView(lockTitleView, offset: 6) + .centerY(lockTitleView) + .height(36) + for label in digitLabels { + label.layoutChain.width(27).height(36) + } + colonLabel.layoutChain.width(8) + + heroView.layoutChain + .top(118) + .centerX() + .width(180) + .height(180) + + unlockButton.layoutChain + .top(304) + .edgesHorzontal(20) + .height(56) + + rejectButton.layoutChain + .topToBottomOfView(unlockButton, offset: 4) + .edgesHorzontal(20) + .bottom(8) + + unlockButton.addTarget(self, action: #selector(tapUnlock), for: .touchUpInside) + rejectButton.addTarget(self, action: #selector(tapReject), for: .touchUpInside) + } + + @objc private func tapUnlock() { + onUnlock?() + } + + @objc private func tapReject() { + onReject?() + } +} + +private final class UnlockTimeDigitLabel: UILabel { + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#293445") + textColor = .white + font = FontManager.boboBold(20) + textAlignment = .center + layer.cornerRadius = 8 + clipsToBounds = true + text = "0" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class UnlockRequestGradientView: UIView { + override class var layerClass: AnyClass { + CAGradientLayer.self + } + + override init(frame: CGRect) { + super.init(frame: frame) + guard let layer = layer as? CAGradientLayer else { return } + layer.colors = [ + UIColor(hexStr: "#91DEFA").cgColor, + UIColor(hexStr: "#D9F4FD").cgColor, + UIColor.white.cgColor + ] + layer.locations = [0, 0.58, 1] + layer.startPoint = CGPoint(x: 0.5, y: 0) + layer.endPoint = CGPoint(x: 0.5, y: 1) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift new file mode 100644 index 00000000..1042a60c --- /dev/null +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift @@ -0,0 +1,396 @@ +// +// PigeonMessageVC.swift +// QuickLocation +// + +import UIKit +import AVFoundation +import CoreLocation +import SwiftyUserDefaults +import RxSwift + +final class PigeonMessageVC: BaseViewController { + + override var isNavigationBarHidden: Bool { true } + override var prefersStatusBarHidden: Bool { true } + + private var rootView: PigeonMessageView! + private var members: [GroupMemberModel] = [] + private var selectedMember: GroupMemberModel? + private var selectedImage: UIImage? + + private var audioRecorder: AVAudioRecorder? + private var audioPlayer: AVAudioPlayer? + private var recordURL: URL? + private var recordTimer: Timer? + private var playbackTimer: Timer? + 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 + } + + override func viewDidLoad() { + super.viewDidLoad() + bindActions() + setupRecordingGesture() + loadMembers() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + guard isMovingFromParent || navigationController?.isBeingDismissed == true else { return } + cleanupAudio(deleteFile: true) + } + + private func bindActions() { + rootView.backButton.addTarget(self, action: #selector(goBack), for: .touchUpInside) + rootView.historyButton.addTarget(self, action: #selector(showHistory), for: .touchUpInside) + rootView.changeRecipientButton.addTarget( + self, + action: #selector(changeRecipient), + for: .touchUpInside + ) + rootView.addImageButton.addTarget(self, action: #selector(selectImage), for: .touchUpInside) + rootView.deleteImageButton.addTarget(self, action: #selector(deleteImage), for: .touchUpInside) + rootView.playPauseButton.addTarget(self, action: #selector(togglePlayback), for: .touchUpInside) + rootView.sendButton.addTarget(self, action: #selector(sendMessage), for: .touchUpInside) + rootView.onModeChanged = { [weak self] _ in + self?.stopPlayback() + if self?.audioRecorder?.isRecording == true { + self?.stopRecording(cancel: true) + } + } + } + + private func setupRecordingGesture() { + let gesture = UILongPressGestureRecognizer( + target: self, + action: #selector(handleRecordGesture(_:)) + ) + gesture.minimumPressDuration = 0.15 + rootView.recordButton.addGestureRecognizer(gesture) + } + + private func loadMembers() { + DLToast.showLoading() + GroupService.groupInfo().subscribe(onNext: { [weak self] response in + DLToast.dismiss() + guard let self, let model = response.model else { return } + self.members = model.select_group_employee + self.selectedMember = self.members.first + self.refreshRecipient() + }).disposed(by: disposeBag) + } + + private func refreshRecipient() { + rootView.configureRecipient( + selectedMember, + distanceText: distanceText(for: selectedMember) + ) + } + + private func distanceText(for member: GroupMemberModel?) -> String { + guard let member, + let latitude = Defaults[\.currentLatitude], + let longitude = Defaults[\.currentLongitude] else { + return "与您相距 --" + } + let parts = member.last_position.components(separatedBy: ":") + guard parts.count >= 2, + let memberLatitude = Double(parts[0]), + let memberLongitude = Double(parts[1]) else { + return "与您相距 --" + } + let current = CLLocation(latitude: latitude, longitude: longitude) + let target = CLLocation(latitude: memberLatitude, longitude: memberLongitude) + let kilometers = current.distance(from: target) / 1000 + if kilometers < 1 { + return "与您相距 \(Int(kilometers * 1000))m" + } + return String(format: "与您相距 %.1fkm", kilometers) + } + + @objc private func goBack() { + navigationController?.popViewController(animated: true) + } + + @objc private func showHistory() { + DLToast.show(text: "传书记录敬请期待") + } + + @objc private func changeRecipient() { + guard !members.isEmpty else { + DLToast.show(text: "暂无可选收件人") + return + } + PigeonRecipientPickerView.show( + members: members, + selectedId: selectedMember?.user_id ?? "" + ) { [weak self] member in + self?.selectedMember = member + self?.refreshRecipient() + } + } + + @objc private func selectImage() { + imagePicker.presentImagePickerPopup() + } + + @objc private func deleteImage() { + selectedImage = nil + rootView.setSelectedImage(nil) + } + + @objc private func handleRecordGesture(_ gesture: UILongPressGestureRecognizer) { + switch gesture.state { + case .began: + isPressingRecord = true + requestMicrophoneAndStart() + case .ended: + isPressingRecord = false + if audioRecorder?.isRecording == true { + stopRecording(cancel: false) + } + case .cancelled, .failed: + isPressingRecord = false + stopRecording(cancel: true) + default: + break + } + } + + private func requestMicrophoneAndStart() { + guard let manager = AuthorizeManager.manager(type: .microphone) else { return } + switch manager.authorizeStatus() { + case .authorized: + if isPressingRecord { + startRecording() + } + case .notDetermined: + manager.authorize { [weak self] status in + guard let self else { return } + if status == .authorized, self.isPressingRecord { + self.startRecording() + } else if status != .authorized { + self.showMicrophoneDenied() + } + } + case .restricted, .denied: + showMicrophoneDenied() + } + } + + private func showMicrophoneDenied() { + Permission.openAppSetting( + title: "无法使用麦克风", + message: "请在系统设置中允许访问麦克风" + ) + } + + private func startRecording() { + stopPlayback() + if let recordURL { + try? FileManager.default.removeItem(at: recordURL) + } + + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory( + .playAndRecord, + mode: .default, + options: [.defaultToSpeaker, .allowBluetoothHFP] + ) + try session.setActive(true) + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("pigeon_\(UUID().uuidString).wav") + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: 8000, + AVNumberOfChannelsKey: 1, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false + ] + let recorder = try AVAudioRecorder(url: url, settings: settings) + recorder.isMeteringEnabled = true + recorder.prepareToRecord() + recorder.record(forDuration: 60) + + recordURL = url + audioRecorder = recorder + recordedDuration = 0 + rootView.clearVoiceDraft() + rootView.setRecording(isRecording: true) + rootView.setVoiceDuration(0, hasRecording: false) + startRecordTimer() + } catch { + DLToast.show(text: "录音启动失败") + stopRecording(cancel: true) + } + } + + private func startRecordTimer() { + recordTimer?.invalidate() + let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in + guard let self, let recorder = self.audioRecorder else { return } + recorder.updateMeters() + let level = pow(10, CGFloat(recorder.averagePower(forChannel: 0)) / 38) + self.rootView.pushMeterLevel(level) + self.rootView.setVoiceDuration(recorder.currentTime, hasRecording: false) + if recorder.currentTime >= 59.95 { + self.isPressingRecord = false + self.stopRecording(cancel: false) + } + } + RunLoop.main.add(timer, forMode: .common) + recordTimer = timer + } + + private func stopRecording(cancel: Bool) { + recordTimer?.invalidate() + recordTimer = nil + + let duration = audioRecorder?.currentTime ?? 0 + audioRecorder?.stop() + audioRecorder = nil + rootView.setRecording(isRecording: false) + try? AVAudioSession.sharedInstance().setActive(false) + + guard !cancel, duration >= 1, recordURL != nil else { + if !cancel, duration > 0 { + DLToast.show(text: "录音时间不能少于1秒") + } + if let recordURL { + try? FileManager.default.removeItem(at: recordURL) + } + self.recordURL = nil + recordedDuration = 0 + rootView.clearVoiceDraft() + return + } + + recordedDuration = min(60, duration) + rootView.setVoiceDuration(recordedDuration, hasRecording: true) + rootView.setPlaying(false) + } + + @objc private func togglePlayback() { + guard let recordURL else { return } + if let player = audioPlayer { + if player.isPlaying { + player.pause() + rootView.setPlaying(false) + playbackTimer?.invalidate() + playbackTimer = nil + } else { + player.play() + rootView.setPlaying(true) + startPlaybackTimer() + } + return + } + + do { + let player = try AVAudioPlayer(contentsOf: recordURL) + player.delegate = self + player.isMeteringEnabled = true + player.prepareToPlay() + player.play() + audioPlayer = player + rootView.setPlaying(true) + startPlaybackTimer() + } catch { + DLToast.show(text: "语音播放失败") + } + } + + private func startPlaybackTimer() { + playbackTimer?.invalidate() + let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in + guard let self, let player = self.audioPlayer else { return } + player.updateMeters() + let level = pow(10, CGFloat(player.averagePower(forChannel: 0)) / 38) + self.rootView.pushMeterLevel(level) + self.rootView.setVoiceDuration(player.currentTime, hasRecording: true) + } + RunLoop.main.add(timer, forMode: .common) + playbackTimer = timer + } + + private func stopPlayback() { + playbackTimer?.invalidate() + playbackTimer = nil + audioPlayer?.stop() + audioPlayer = nil + rootView?.setPlaying(false) + if recordedDuration > 0 { + rootView?.setVoiceDuration(recordedDuration, hasRecording: true) + } + } + + @objc private func sendMessage() { + guard selectedMember != nil else { + DLToast.show(text: "请先选择收件人") + return + } + + switch rootView.mode { + case .image: + guard selectedImage != nil else { + DLToast.show(text: "请先选择一张图片") + return + } + selectedImage = nil + rootView.clearImageDraft() + case .voice: + guard recordURL != nil, recordedDuration >= 1 else { + DLToast.show(text: "请先完成录音") + return + } + cleanupAudio(deleteFile: true) + rootView.clearVoiceDraft() + } + DLToast.show(text: "发送成功") + } + + private func cleanupAudio(deleteFile: Bool) { + recordTimer?.invalidate() + playbackTimer?.invalidate() + recordTimer = nil + playbackTimer = nil + audioRecorder?.stop() + audioRecorder = nil + audioPlayer?.stop() + audioPlayer = nil + if deleteFile, let recordURL { + try? FileManager.default.removeItem(at: recordURL) + self.recordURL = nil + } + recordedDuration = 0 + try? AVAudioSession.sharedInstance().setActive(false) + } +} + +extension PigeonMessageVC: AVAudioPlayerDelegate { + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + playbackTimer?.invalidate() + playbackTimer = nil + audioPlayer = nil + rootView.setPlaying(false) + rootView.setVoiceDuration(recordedDuration, hasRecording: recordedDuration >= 1) + } +} diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift new file mode 100644 index 00000000..72fb01f0 --- /dev/null +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift @@ -0,0 +1,946 @@ +// +// PigeonMessageView.swift +// QuickLocation +// + +import UIKit + +enum PigeonMessageMode { + case image + case voice +} + +final class PigeonMessageView: UIView, UITextFieldDelegate { + + private static let designWidth: CGFloat = 375 + + var onModeChanged: ((PigeonMessageMode) -> Void)? + var mode: PigeonMessageMode = .image { + didSet { + updateMode() + onModeChanged?(mode) + } + } + + let backButton = UIButton(type: .custom) + let historyButton = UIButton(type: .custom) + let changeRecipientButton = UIButton(type: .custom) + let addImageButton = UIButton(type: .custom) + let deleteImageButton = UIButton(type: .custom) + let recordButton = UIButton(type: .custom) + let playPauseButton = UIButton(type: .custom) + let sendButton = UIButton(type: .custom) + + private let recipientAvatarView = UIImageView() + private let recipientNameLabel = UILabel() + private let recipientIdLabel = UILabel() + private let recipientOnlineDotView = UIView() + private let recipientOnlineLabel = UILabel() + private let recipientStatusDividerView = UIView() + private let recipientLocationIconView = UIImageView() + private let recipientDistancePrefixLabel = UILabel() + private let recipientDistanceLabel = UILabel() + private let imageModeButton = UIButton(type: .custom) + private let voiceModeButton = UIButton(type: .custom) + private let imageEditorView = UIView() + private let voiceEditorView = UIView() + private let selectedImageView = UIImageView() + private let imagePlaceholderLabel = UILabel() + private let captionField = UITextField() + private let captionCountLabel = UILabel() + private let imageThumbnailView = UIImageView() + private let imageThumbnailButton = UIButton(type: .custom) + private let addTile = UIView() + private let voiceThumbnailView = UIView() + private let recordOuterHaloView = UIView() + private let recordInnerHaloView = UIView() + private let recordingHintLabel = UILabel() + private let voiceTimeLabel = UILabel() + private let waveformView = PigeonWaveformView() + private var addTileLeadingWithImage: NSLayoutConstraint? + private var addTileLeadingWithoutImage: NSLayoutConstraint? + private var isRecording = false + + var captionText: String { + captionField.text ?? "" + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#F9F9F9") + setupUI() + configureRecipient(nil, distanceText: "与您相距 --") + setSelectedImage(nil) + clearVoiceDraft() + updateMode() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var designScale: CGFloat { + min(1.104, max(0.853, bounds.width / Self.designWidth)) + } + + private func scaled(_ value: CGFloat) -> CGFloat { + value * designScale + } + + func configureRecipient(_ member: GroupMemberModel?, distanceText: String) { + recipientAvatarView.image = member?.userIcon ?? UIImage(named: "Common/default_avatar") + recipientNameLabel.text = member?.nick_name ?? "暂无圈子成员" + recipientIdLabel.text = member.map { "ID:\($0.user_id)" } ?? "ID:--" + let isOnline = member?.is_online == true + recipientOnlineLabel.text = isOnline ? "在线" : "离线" + recipientOnlineDotView.backgroundColor = isOnline + ? UIColor(hexStr: "#54D34D") + : UIColor(hexStr: "#C7CBD1") + recipientDistanceLabel.text = distanceText + .replacingOccurrences(of: "与您相距 ", with: "") + changeRecipientButton.isEnabled = member != nil + changeRecipientButton.alpha = member == nil ? 0.55 : 1 + } + + func setSelectedImage(_ image: UIImage?) { + selectedImageView.image = image + selectedImageView.isHidden = image == nil + imagePlaceholderLabel.isHidden = image != nil + imageThumbnailView.image = image + imageThumbnailButton.isHidden = image == nil + deleteImageButton.isHidden = image == nil + addTile.alpha = 1 + updateAddTilePosition(hasImage: image != nil) + } + + func setRecording(isRecording: Bool) { + self.isRecording = isRecording + recordingHintLabel.text = isRecording ? "松开发送" : "按住录音 松开发送" + recordOuterHaloView.backgroundColor = UIColor( + hexStr: isRecording ? "#DDF5FF" : "#F2FBFF" + ) + recordInnerHaloView.backgroundColor = UIColor( + hexStr: isRecording ? "#CDEFFF" : "#E6F7FF" + ) + recordButton.alpha = isRecording ? 0.86 : 1 + playPauseButton.isEnabled = !isRecording + } + + func setVoiceDuration(_ seconds: TimeInterval, hasRecording: Bool) { + let value = min(60, max(0, Int(seconds.rounded(.down)))) + let currentTime = String(format: "%02d″", value) + let timeText = "\(currentTime)/60″" + let attributedTime = NSMutableAttributedString( + string: timeText, + attributes: [.foregroundColor: UIColor(hexStr: "#AAAAAA")] + ) + attributedTime.addAttribute( + .foregroundColor, + value: UIColor(hexStr: "#13B6F1"), + range: NSRange(location: 0, length: currentTime.utf16.count) + ) + voiceTimeLabel.attributedText = attributedTime + voiceThumbnailView.alpha = 1 + playPauseButton.isEnabled = hasRecording + if !hasRecording, !isRecording { + waveformView.reset() + } + } + + func setPlaying(_ isPlaying: Bool) { + let image: UIImage? + if isPlaying { + image = UIImage(named: "PigeonMessage/pause") + } else { + image = UIImage( + systemName: "play.circle.fill", + withConfiguration: UIImage.SymbolConfiguration( + pointSize: scaled(26), + weight: .semibold + ) + )?.withTintColor(UIColor(hexStr: "#11B8F4"), renderingMode: .alwaysOriginal) + } + playPauseButton.setImage(image, for: .normal) + } + + func pushMeterLevel(_ level: CGFloat) { + waveformView.push(level: level) + } + + func clearImageDraft() { + captionField.text = nil + captionCountLabel.text = "0/20" + setSelectedImage(nil) + } + + func clearVoiceDraft() { + setRecording(isRecording: false) + setVoiceDuration(0, hasRecording: false) + setPlaying(false) + } + + private func setupUI() { + let headerView = UIImageView(image: UIImage(named: "PigeonMessage/bg")) + headerView.contentMode = .scaleToFill + headerView.clipsToBounds = true + addSubview(headerView) + headerView.layoutChain.top().edgesHorzontal().height(scaled(210)) + + backButton.accessibilityLabel = "返回" + historyButton.accessibilityLabel = "传书记录" + addSubview(backButton) + addSubview(historyButton) + backButton.layoutChain + .top(scaled(38)) + .left(scaled(4)) + .width(scaled(52)) + .height(scaled(52)) + historyButton.layoutChain + .top(scaled(38)) + .right(scaled(4)) + .width(scaled(88)) + .height(scaled(52)) + + sendButton.setTitle("发送", for: .normal) + sendButton.setTitleColor(.white, for: .normal) + sendButton.titleLabel?.font = FontManager.boboBold(scaled(18)) + sendButton.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + sendButton.layer.cornerRadius = 20 + sendButton.clipsToBounds = true + addSubview(sendButton) + sendButton.layoutChain + .bottom(kSafeBottomMargin + 10) + .edgesHorzontal(30) + .height(56) + + let scrollView = UIScrollView() + scrollView.backgroundColor = UIColor(hexStr: "#F9F9F9") + scrollView.layer.cornerRadius = scaled(30) + scrollView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] +// scrollView.alwaysBounceVertical = true + scrollView.showsVerticalScrollIndicator = false + addSubview(scrollView) + scrollView.layoutChain + .top(scaled(174)) + .edgesHorzontal() + .bottomToTopOfView(sendButton, offset: -scaled(12)) + + let contentView = UIView() + contentView.backgroundColor = UIColor(hexStr: "#F9F9F9") + scrollView.addSubview(contentView) + contentView.layoutChain.edges().widthToView(scrollView) + + let sectionSparkle = UIImageView(image: UIImage(named: "PigeonMessage/sparkle")) + sectionSparkle.contentMode = .scaleAspectFit + contentView.addSubview(sectionSparkle) + sectionSparkle.layoutChain + .top(scaled(21)) + .left(scaled(28)) + .width(scaled(11)) + .height(scaled(11)) + + let sectionTitle = UILabel() + sectionTitle.text = "选择收件人" + sectionTitle.font = FontManager.boboBold(scaled(18)) + sectionTitle.textColor = UIColor(hexStr: "#293445") + contentView.addSubview(sectionTitle) + sectionTitle.layoutChain + .top(scaled(16)) + .leftToRightOfView(sectionSparkle, offset: scaled(8)) + + let recipientCard = UIView() + recipientCard.backgroundColor = .white + recipientCard.layer.cornerRadius = scaled(20) + contentView.addSubview(recipientCard) + recipientCard.layoutChain + .topToBottomOfView(sectionTitle, offset: scaled(8)) + .edgesHorzontal(scaled(15)) + .height(scaled(100)) + setupRecipientCard(recipientCard) + + let modeContainer = UIView() + modeContainer.backgroundColor = .white + modeContainer.layer.cornerRadius = scaled(22) + contentView.addSubview(modeContainer) + modeContainer.layoutChain + .topToBottomOfView(recipientCard, offset: scaled(12)) + .edgesHorzontal(scaled(15)) + .height(scaled(44)) + setupModeButtons(modeContainer) + + contentView.addSubview(imageEditorView) + contentView.addSubview(voiceEditorView) + imageEditorView.layoutChain + .topToBottomOfView(modeContainer, offset: scaled(12)) + .edgesHorzontal(scaled(15)) + .height(scaled(190)) + voiceEditorView.layoutChain + .topToBottomOfView(modeContainer, offset: scaled(12)) + .edgesHorzontal(scaled(15)) + .height(scaled(190)) + setupImageEditor() + setupVoiceEditor() + + let thumbnailRow = UIView() + contentView.addSubview(thumbnailRow) + thumbnailRow.layoutChain + .topToBottomOfView(imageEditorView, offset: scaled(14)) + .edgesHorzontal(scaled(17)) + .height(scaled(60)) + .bottom(scaled(20)) + setupThumbnailRow(thumbnailRow) + } + + private func setupRecipientCard(_ card: UIView) { + recipientAvatarView.contentMode = .scaleAspectFill + recipientAvatarView.clipsToBounds = true + recipientAvatarView.layer.cornerRadius = scaled(29) + card.addSubview(recipientAvatarView) + recipientAvatarView.layoutChain + .left(scaled(18)) + .centerY() + .width(scaled(58)) + .height(scaled(58)) + + recipientNameLabel.font = .systemFont(ofSize: scaled(17), weight: .bold) + recipientNameLabel.textColor = UIColor(hexStr: "#293445") + recipientNameLabel.lineBreakMode = .byTruncatingTail + card.addSubview(recipientNameLabel) + recipientNameLabel.layoutChain + .top(scaled(18)) + .leftToRightOfView(recipientAvatarView, offset: scaled(16)) + + recipientIdLabel.font = .systemFont(ofSize: scaled(12)) + recipientIdLabel.textColor = UIColor(hexStr: "#767676") + recipientIdLabel.lineBreakMode = .byTruncatingMiddle + card.addSubview(recipientIdLabel) + recipientIdLabel.layoutChain + .topToBottomOfView(recipientNameLabel, offset: scaled(4)) + .leftToView(recipientNameLabel) + + recipientOnlineDotView.layer.cornerRadius = scaled(3) + card.addSubview(recipientOnlineDotView) + recipientOnlineDotView.layoutChain + .topToBottomOfView(recipientIdLabel, offset: scaled(10)) + .leftToView(recipientNameLabel) + .width(scaled(6)) + .height(scaled(6)) + + recipientOnlineLabel.font = .systemFont(ofSize: scaled(12), weight: .medium) + recipientOnlineLabel.textColor = UIColor(hexStr: "#747A84") + card.addSubview(recipientOnlineLabel) + recipientOnlineLabel.layoutChain + .centerY(recipientOnlineDotView) + .leftToRightOfView(recipientOnlineDotView, offset: scaled(6)) + + recipientStatusDividerView.backgroundColor = UIColor(hexStr: "#D8DBDF") + card.addSubview(recipientStatusDividerView) + recipientStatusDividerView.layoutChain + .centerY(recipientOnlineDotView) + .leftToRightOfView(recipientOnlineLabel, offset: scaled(10)) + .width(scaled(1)) + .height(scaled(14)) + + recipientLocationIconView.image = UIImage( + systemName: "mappin.circle", + withConfiguration: UIImage.SymbolConfiguration( + pointSize: scaled(13), + weight: .regular + ) + ) + recipientLocationIconView.tintColor = UIColor(hexStr: "#858A92") + recipientLocationIconView.contentMode = .scaleAspectFit + card.addSubview(recipientLocationIconView) + recipientLocationIconView.layoutChain + .centerY(recipientOnlineDotView) + .leftToRightOfView(recipientStatusDividerView, offset: scaled(9)) + .width(scaled(14)) + .height(scaled(14)) + + recipientDistancePrefixLabel.text = "与您相距" + recipientDistancePrefixLabel.font = .systemFont(ofSize: scaled(12)) + recipientDistancePrefixLabel.textColor = UIColor(hexStr: "#747A84") + card.addSubview(recipientDistancePrefixLabel) + recipientDistancePrefixLabel.layoutChain + .centerY(recipientOnlineDotView) + .leftToRightOfView(recipientLocationIconView, offset: scaled(4)) + + recipientDistanceLabel.font = .systemFont(ofSize: scaled(12)) + recipientDistanceLabel.textColor = UIColor(hexStr: "#00ADFE") + card.addSubview(recipientDistanceLabel) + recipientDistanceLabel.layoutChain + .centerY(recipientOnlineDotView) + .leftToRightOfView(recipientDistancePrefixLabel, offset: scaled(4)) + + changeRecipientButton.setTitle("更换收件人", for: .normal) + changeRecipientButton.setTitleColor(UIColor(hexStr: "#00ADFE"), for: .normal) + changeRecipientButton.titleLabel?.font = .systemFont(ofSize: scaled(12), weight: .medium) + changeRecipientButton.backgroundColor = UIColor(hexStr: "#F6F6F6") + changeRecipientButton.layer.cornerRadius = scaled(15) + card.addSubview(changeRecipientButton) + changeRecipientButton.layoutChain + .right(scaled(12)) + .centerY() + .width(scaled(70)) + .height(scaled(30)) + recipientNameLabel.layoutChain.rightToLeftOfView( + changeRecipientButton, + offset: -scaled(8), + relation: .lessThanOrEqual + ) + recipientIdLabel.layoutChain.rightToLeftOfView( + changeRecipientButton, + offset: -scaled(8), + relation: .lessThanOrEqual + ) + } + + private func setupModeButtons(_ container: UIView) { + configureModeButton( + imageModeButton, + title: "图片", + imageName: "PigeonMessage/gallery" + ) + configureModeButton( + voiceModeButton, + title: "语音", + imageName: "PigeonMessage/microphone" + ) + container.addSubview(imageModeButton) + container.addSubview(voiceModeButton) + imageModeButton.layoutChain + .edgesVertical(scaled(4)) + .left(scaled(4)) + .widthToView(container, offset: -scaled(6), multiplier: 0.5) + voiceModeButton.layoutChain + .edgesVertical(scaled(4)) + .right(scaled(4)) + .widthToView(container, offset: -scaled(6), multiplier: 0.5) + imageModeButton.addTarget(self, action: #selector(selectImageMode), for: .touchUpInside) + voiceModeButton.addTarget(self, action: #selector(selectVoiceMode), for: .touchUpInside) + } + + private func configureModeButton(_ button: UIButton, title: String, imageName: String) { + var titleAttributes = AttributeContainer() + titleAttributes.font = FontManager.boboBold(scaled(16)) + var configuration = UIButton.Configuration.plain() + configuration.attributedTitle = AttributedString(title, attributes: titleAttributes) + configuration.image = UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate) + configuration.imagePadding = scaled(6) + configuration.contentInsets = .zero + button.configuration = configuration + button.layer.cornerRadius = scaled(18) + } + + private func setupImageEditor() { + imageEditorView.backgroundColor = UIColor(hexStr: "#DDE1E5") + imageEditorView.layer.cornerRadius = scaled(22) + imageEditorView.clipsToBounds = true + + selectedImageView.contentMode = .scaleAspectFill + selectedImageView.clipsToBounds = true + imageEditorView.addSubview(selectedImageView) + selectedImageView.layoutChain.edges() + + imagePlaceholderLabel.text = "点击下方添加一张图片" + imagePlaceholderLabel.font = .systemFont(ofSize: scaled(14), weight: .medium) + imagePlaceholderLabel.textColor = UIColor(hexStr: "#9298A1") + imagePlaceholderLabel.textAlignment = .center + imageEditorView.addSubview(imagePlaceholderLabel) + imagePlaceholderLabel.layoutChain.centerX().centerY() + + let captionBackground = UIView() + captionBackground.backgroundColor = UIColor.white.withAlphaComponent(0.9) + captionBackground.layer.cornerRadius = scaled(12) + imageEditorView.addSubview(captionBackground) + captionBackground.layoutChain + .left(scaled(10)) + .right(scaled(60)) + .bottom(scaled(10)) + .height(scaled(40)) + + captionField.attributedPlaceholder = NSAttributedString( + string: "随便说点什么吧~", + attributes: [.foregroundColor: UIColor(hexStr: "#A7ABB2")] + ) + captionField.font = .systemFont(ofSize: scaled(14)) + captionField.textColor = UIColor(hexStr: "#293445") + captionField.delegate = self + captionField.addTarget(self, action: #selector(captionChanged), for: .editingChanged) + captionBackground.addSubview(captionField) + captionBackground.addSubview(captionCountLabel) + captionField.layoutChain + .left(scaled(14)) + .centerY() + .right(scaled(48)) + .height(scaled(34)) + captionCountLabel.text = "0/20" + captionCountLabel.font = .systemFont(ofSize: scaled(14)) + captionCountLabel.textColor = UIColor(hexStr: "#A7ABB2") + captionCountLabel.layoutChain.right(scaled(10)).centerY() + + deleteImageButton.setImage(UIImage(named: "PigeonMessage/trash"), for: .normal) + deleteImageButton.backgroundColor = UIColor.white.withAlphaComponent(0.92) + deleteImageButton.layer.cornerRadius = scaled(12) + imageEditorView.addSubview(deleteImageButton) + deleteImageButton.layoutChain + .right(scaled(10)) + .bottom(scaled(10)) + .width(scaled(40)) + .height(scaled(40)) + } + + private func setupVoiceEditor() { + voiceEditorView.backgroundColor = .white + voiceEditorView.layer.cornerRadius = scaled(22) + + recordOuterHaloView.backgroundColor = UIColor(hexStr: "#F2FBFF") + recordOuterHaloView.layer.cornerRadius = scaled(46) + voiceEditorView.addSubview(recordOuterHaloView) + recordOuterHaloView.layoutChain + .top(scaled(15)) + .centerX() + .width(scaled(92)) + .height(scaled(92)) + + recordInnerHaloView.backgroundColor = UIColor(hexStr: "#E6F7FF") + recordInnerHaloView.layer.cornerRadius = scaled(37) + voiceEditorView.addSubview(recordInnerHaloView) + recordInnerHaloView.layoutChain + .centerX(recordOuterHaloView) + .centerY(recordOuterHaloView) + .width(scaled(74)) + .height(scaled(74)) + + recordButton.setImage(UIImage(named: "PigeonMessage/record_microphone"), for: .normal) + voiceEditorView.addSubview(recordButton) + recordButton.layoutChain + .centerX(recordOuterHaloView) + .centerY(recordOuterHaloView) + .width(scaled(92)) + .height(scaled(92)) + + recordingHintLabel.text = "按住录音 松开发送" + recordingHintLabel.font = .systemFont(ofSize: scaled(14)) + recordingHintLabel.textColor = UIColor(hexStr: "#A7ABB2") + voiceEditorView.addSubview(recordingHintLabel) + recordingHintLabel.layoutChain + .topToBottomOfView(recordOuterHaloView, offset: scaled(5)) + .centerX() + + let playbackBar = UIView() + playbackBar.backgroundColor = UIColor(hexStr: "#F5F5F6") + playbackBar.layer.cornerRadius = scaled(12) + voiceEditorView.addSubview(playbackBar) + playbackBar.layoutChain + .left(scaled(10)) + .right(scaled(10)) + .bottom(scaled(10)) + .height(scaled(40)) + + setPlaying(false) + playPauseButton.isEnabled = false + playbackBar.addSubview(playPauseButton) + playPauseButton.layoutChain + .left(scaled(8)) + .centerY() + .width(scaled(28)) + .height(scaled(28)) + + playbackBar.addSubview(waveformView) + waveformView.layoutChain + .leftToRightOfView(playPauseButton, offset: scaled(8)) + .centerY() + .right(scaled(66)) + .height(scaled(24)) + + voiceTimeLabel.text = "00″/60″" + voiceTimeLabel.font = .systemFont(ofSize: scaled(16)) + voiceTimeLabel.textColor = UIColor(hexStr: "#13B6F1") + playbackBar.addSubview(voiceTimeLabel) + voiceTimeLabel.layoutChain.right(scaled(10)).centerY() + } + + private func setupThumbnailRow(_ row: UIView) { + imageThumbnailButton.layer.cornerRadius = scaled(20) + imageThumbnailButton.layer.borderWidth = scaled(2) + imageThumbnailButton.layer.borderColor = UIColor(hexStr: "#09B6F4").cgColor + imageThumbnailButton.clipsToBounds = true + row.addSubview(imageThumbnailButton) + imageThumbnailButton.layoutChain + .left() + .top() + .width(scaled(60)) + .height(scaled(60)) + + imageThumbnailView.contentMode = .scaleAspectFill + imageThumbnailView.isUserInteractionEnabled = false + imageThumbnailButton.addSubview(imageThumbnailView) + imageThumbnailView.layoutChain.edges() + + let tick = UIImageView(image: UIImage(named: "PigeonMessage/tick_circle")) + imageThumbnailButton.addSubview(tick) + tick.layoutChain + .right(scaled(6)) + .bottom(scaled(5)) + .width(scaled(16)) + .height(scaled(16)) + + addTile.backgroundColor = UIColor(hexStr: "#EBECEE") + addTile.layer.cornerRadius = scaled(20) + row.addSubview(addTile) + addTile.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + addTile.topAnchor.constraint(equalTo: row.topAnchor), + addTile.widthAnchor.constraint(equalToConstant: scaled(60)), + addTile.heightAnchor.constraint(equalToConstant: scaled(60)) + ]) + addTileLeadingWithImage = addTile.leadingAnchor.constraint( + equalTo: imageThumbnailButton.trailingAnchor, + constant: scaled(10) + ) + addTileLeadingWithoutImage = addTile.leadingAnchor.constraint(equalTo: row.leadingAnchor) + addTileLeadingWithoutImage?.isActive = true + addTile.addSubview(addImageButton) + addImageButton.layoutChain.edges() + let addIcon = UIImageView(image: UIImage(named: "PigeonMessage/add")) + addIcon.contentMode = .scaleAspectFit + let addLabel = UILabel() + addLabel.text = "添加" + addLabel.font = .systemFont(ofSize: scaled(13)) + addLabel.textColor = UIColor(hexStr: "#ADB1B8") + addTile.addSubview(addIcon) + addTile.addSubview(addLabel) + addIcon.layoutChain + .top(scaled(8)) + .centerX() + .width(scaled(24)) + .height(scaled(24)) + addLabel.layoutChain + .topToBottomOfView(addIcon, offset: scaled(2)) + .centerX() + + voiceThumbnailView.backgroundColor = UIColor(hexStr: "#F2F3F4") + voiceThumbnailView.layer.cornerRadius = scaled(20) + voiceThumbnailView.layer.borderWidth = scaled(2) + voiceThumbnailView.layer.borderColor = UIColor(hexStr: "#09B6F4").cgColor + row.addSubview(voiceThumbnailView) + voiceThumbnailView.layoutChain + .left() + .top() + .width(scaled(60)) + .height(scaled(60)) + let mic = UIImageView( + image: UIImage(named: "PigeonMessage/microphone_thumb")?.withRenderingMode(.alwaysTemplate) + ) + mic.tintColor = UIColor(hexStr: "#AEB3BA") + mic.contentMode = .scaleAspectFit + let voiceLabel = UILabel() + voiceLabel.text = "录音" + voiceLabel.font = .systemFont(ofSize: scaled(13)) + voiceLabel.textColor = UIColor(hexStr: "#969CA5") + voiceThumbnailView.addSubview(mic) + voiceThumbnailView.addSubview(voiceLabel) + mic.layoutChain + .top(scaled(7)) + .centerX() + .width(scaled(24)) + .height(scaled(24)) + voiceLabel.layoutChain + .topToBottomOfView(mic, offset: scaled(2)) + .centerX() + } + + @objc private func selectImageMode() { + mode = .image + } + + @objc private func selectVoiceMode() { + mode = .voice + } + + @objc private func captionChanged() { + let text = captionField.text ?? "" + if text.count > 20 { + captionField.text = String(text.prefix(20)) + } + captionCountLabel.text = "\((captionField.text ?? "").count)/20" + } + + func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + guard let current = textField.text, + let stringRange = Range(range, in: current) else { return true } + return current.replacingCharacters(in: stringRange, with: string).count <= 20 + } + + private func updateAddTilePosition(hasImage: Bool) { + addTileLeadingWithImage?.isActive = false + addTileLeadingWithoutImage?.isActive = false + if hasImage { + addTileLeadingWithImage?.isActive = true + } else { + addTileLeadingWithoutImage?.isActive = true + } + } + + private func updateMode() { + let isImage = mode == .image + imageEditorView.isHidden = !isImage + voiceEditorView.isHidden = isImage + imageThumbnailButton.isHidden = !isImage || selectedImageView.image == nil + addTile.isHidden = !isImage + voiceThumbnailView.isHidden = isImage + styleModeButton(imageModeButton, selected: isImage) + styleModeButton(voiceModeButton, selected: !isImage) + } + + private func styleModeButton(_ button: UIButton, selected: Bool) { + button.backgroundColor = selected ? UIColor(hexStr: "#45C2FF") : .clear + let foregroundColor = selected ? UIColor.white : UIColor(hexStr: "#A5A5A5") + var configuration = button.configuration + configuration?.baseForegroundColor = foregroundColor + button.configuration = configuration + button.tintColor = foregroundColor + } +} + +final class PigeonGradientButton: UIButton { + private let gradient = CAGradientLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + gradient.colors = [ + UIColor(hexStr: "#00ADFE").cgColor, + UIColor(hexStr: "#45C2FF").cgColor + ] + gradient.startPoint = CGPoint(x: 0, y: 0.5) + gradient.endPoint = CGPoint(x: 1, y: 0.5) + layer.insertSublayer(gradient, at: 0) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradient.frame = bounds + layer.cornerRadius = bounds.height / 2 + gradient.cornerRadius = bounds.height / 2 + } +} + +final class PigeonWaveformView: UIView { + private static let idleLevels: [CGFloat] = [ + 0.12, 0.12, 0.14, 0.14, 0.16, 0.14, 0.18, + 0.22, 0.18, 0.30, 0.42, 0.22, 0.34, 0.92, + 0.46, 0.28, 0.22, 0.18, 0.24, 0.18, 0.16, + 0.14, 0.14, 0.12, 0.12, 0.12 + ] + + private var levels = PigeonWaveformView.idleLevels + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func push(level: CGFloat) { + levels.removeFirst() + levels.append(min(1, max(0.12, level))) + setNeedsDisplay() + } + + func reset() { + levels = Self.idleLevels + setNeedsDisplay() + } + + override func draw(_ rect: CGRect) { + guard let context = UIGraphicsGetCurrentContext(), !levels.isEmpty else { return } + let activeColor = UIColor(hexStr: "#00ADFE") + let inactiveColor = UIColor(hexStr: "#86D9F8") + let spacing = rect.width / CGFloat(levels.count) + for (index, level) in levels.enumerated() { + let edgeDistance = min(index, levels.count - 1 - index) + let color = edgeDistance < 4 ? inactiveColor : activeColor + let x = CGFloat(index) * spacing + spacing / 2 + if level <= 0.18 { + let radius = min(2, spacing * 0.3) + context.setFillColor(color.cgColor) + context.fillEllipse( + in: CGRect( + x: x - radius, + y: rect.midY - radius, + width: radius * 2, + height: radius * 2 + ) + ) + continue + } + + let height = max(4, rect.height * level) + context.setStrokeColor(color.cgColor) + context.setLineWidth(min(3, spacing * 0.48)) + context.setLineCap(.round) + context.move(to: CGPoint(x: x, y: rect.midY - height / 2)) + context.addLine(to: CGPoint(x: x, y: rect.midY + height / 2)) + context.strokePath() + } + } +} + +final class PigeonRecipientPickerView: UIView { + private let members: [GroupMemberModel] + private let selectedId: String + private let onSelect: (GroupMemberModel) -> Void + private let tableView = UITableView(frame: .zero, style: .plain) + private let sheet = UIView() + + static func show( + members: [GroupMemberModel], + selectedId: String, + onSelect: @escaping (GroupMemberModel) -> Void + ) { + guard let window = kKeyWindow, !members.isEmpty else { return } + let picker = PigeonRecipientPickerView( + frame: CGRect(origin: .zero, size: kScreenSize), + members: members, + selectedId: selectedId, + onSelect: onSelect + ) + window.addSubview(picker) + picker.alpha = 0 + UIView.animate(withDuration: 0.2) { + picker.alpha = 1 + } + } + + private init( + frame: CGRect, + members: [GroupMemberModel], + selectedId: String, + onSelect: @escaping (GroupMemberModel) -> Void + ) { + self.members = members + self.selectedId = selectedId + self.onSelect = onSelect + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + backgroundColor = UIColor.black.withAlphaComponent(0.48) + let backgroundTap = UITapGestureRecognizer(target: self, action: #selector(dismiss)) + backgroundTap.delegate = self + addGestureRecognizer(backgroundTap) + + sheet.backgroundColor = .white + sheet.layer.cornerRadius = 26 + sheet.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + addSubview(sheet) + sheet.layoutChain.bottom().edgesHorzontal().height(min(460, CGFloat(members.count * 72 + 86))) + + let title = UILabel() + title.text = "选择收件人" + title.font = .systemFont(ofSize: 20, weight: .bold) + title.textColor = UIColor(hexStr: "#293445") + sheet.addSubview(title) + title.layoutChain.top(20).left(20) + + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.rowHeight = 68 + tableView.dataSource = self + tableView.delegate = self + tableView.register(PigeonRecipientCell.self, forCellReuseIdentifier: "PigeonRecipientCell") + sheet.addSubview(tableView) + tableView.layoutChain.top(58).edgesHorzontal().bottom(kSafeBottomMargin) + + } + + @objc private func dismiss() { + UIView.animate(withDuration: 0.18, animations: { + self.alpha = 0 + }) { _ in + self.removeFromSuperview() + } + } +} + +extension PigeonRecipientPickerView: UITableViewDataSource, UITableViewDelegate, UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + !(touch.view?.isDescendant(of: sheet) ?? false) + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + members.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: "PigeonRecipientCell", + for: indexPath + ) as? PigeonRecipientCell else { + return UITableViewCell() + } + let member = members[indexPath.row] + cell.configure(member: member, selected: member.user_id == selectedId) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + onSelect(members[indexPath.row]) + dismiss() + } +} + +private final class PigeonRecipientCell: UITableViewCell { + private let avatarView = UIImageView() + private let nameLabel = UILabel() + private let statusLabel = UILabel() + private let tickView = UIImageView(image: UIImage(named: "PigeonMessage/tick_circle")) + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + avatarView.contentMode = .scaleAspectFill + avatarView.clipsToBounds = true + avatarView.layer.cornerRadius = 20 + nameLabel.font = .systemFont(ofSize: 16, weight: .semibold) + nameLabel.textColor = UIColor(hexStr: "#293445") + statusLabel.font = .systemFont(ofSize: 12) + statusLabel.textColor = UIColor(hexStr: "#9EA3AA") + contentView.addSubview(avatarView) + contentView.addSubview(nameLabel) + contentView.addSubview(statusLabel) + contentView.addSubview(tickView) + avatarView.layoutChain.left(20).centerY().width(48).height(48) + nameLabel.layoutChain.top(13).leftToRightOfView(avatarView, offset: 14) + statusLabel.layoutChain.topToBottomOfView(nameLabel, offset: 4).leftToView(nameLabel) + tickView.layoutChain.right(20).centerY().width(16).height(16) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(member: GroupMemberModel, selected: Bool) { + avatarView.image = member.userIcon + nameLabel.text = member.nick_name + statusLabel.text = member.is_online ? "在线" : "离线" + tickView.isHidden = !selected + } +} diff --git a/QuickLocation/Section/Schedule/CreateSchedule/CreateSchedulePopView.swift b/QuickLocation/Section/Schedule/CreateSchedule/CreateSchedulePopView.swift index 7007b424..75cb675c 100644 --- a/QuickLocation/Section/Schedule/CreateSchedule/CreateSchedulePopView.swift +++ b/QuickLocation/Section/Schedule/CreateSchedule/CreateSchedulePopView.swift @@ -17,268 +17,362 @@ import SwiftDate // MARK: - CreateSchedulePopView class CreateSchedulePopView: UIView { - + var disposeBag = DisposeBag() - + /// 途经点拖拽排序中,外层 Pop pan 应让路 + var isReorderingPoints = false + func setupTagData(_ list: [GroupInfoModel]) { let nameArr = list.map { $0.name } tagListView.removeAllTags() tagListView.addTags(nameArr) tagListView.tagViews.forEach { $0.layer.cornerRadius = 4 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.clear.cgColor } - // 强制布局使 TagListView 宽度正确 → 重新排列标签 → intrinsicContentSize 正确 tagListView.setNeedsLayout() tagListView.layoutIfNeeded() } - + // MARK: - UI private func setupUI() { addSubview(lineView) - addSubview(dateView) - addSubview(detailView) + addSubview(headerView) addSubview(scrollView) - + lineView.layoutChain .top(15) .centerX() .width(36) .height(4) - - dateView.layoutChain - .topToBottomOfView(lineView, offset: 19) + + headerView.layoutChain + .topToBottomOfView(lineView, offset: 16) .edgesHorzontal() - .height(22) - - detailView.layoutChain - .topToBottomOfView(dateView, offset: 20) - .edgesHorzontal() - .height(22) - + .height(24) + scrollView.layoutChain - .topToBottomOfView(detailView, offset: 15) + .topToBottomOfView(headerView, offset: 16) .edges(excludingEdge: .top) } - + // MARK: - Views - + lazy var lineView: UIView = { let v = UIView() v.backgroundColor = UIColor(hexStr: "#EBEBEB") v.cornerRadius = 2 return v }() - - lazy var dateView: UIView = { + + lazy var headerView: UIView = { let view = UIView() view.backgroundColor = .clear + + let pin = UIImageView(image: UIImage(named: "Schedule/location")?.withRenderingMode(.alwaysTemplate)) + pin.tintColor = UIColor(hexStr: "#16B3FF") + pin.contentMode = .scaleAspectFit + view.addSubview(pin) + pin.layoutChain.left(16).centerY().width(14).height(14) + let titleLab = UILabel() - titleLab.text = "行程日期:" - titleLab.font = .systemFont(ofSize: 14, weight: .medium) - titleLab.textColor = ThemeManager.shared.color.titleAuxColor + titleLab.text = "行程详情" + titleLab.font = .systemFont(ofSize: 16, weight: .bold) + titleLab.textColor = UIColor(hexStr: "#293445") view.addSubview(titleLab) - titleLab.layoutChain.left(15).centerY() + titleLab.layoutChain.leftToRightOfView(pin, offset: 6).centerY() + + let icon = UIImageView(image: UIImage(named: "Common/arrow_right")) + view.addSubview(icon) + + icon.layoutChain + .right(15) + .centerY() + .width(10) + .height(10) + view.addSubview(dateLab) - dateLab.layoutChain.leftToRightOfView(titleLab).centerY() + dateLab.layoutChain.rightToLeftOfView(icon, offset: -6).centerY() + return view }() - + lazy var dateLab: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 14, weight: .medium) - label.textColor = UIColor(hexStr: "#16B3FF") + label.font = .systemFont(ofSize: 13, weight: .medium) + label.textColor = UIColor(hexStr: "#293445") label.isUserInteractionEnabled = true label.text = Date().toFormat("yyyy年MM月dd日") return label }() - - lazy var detailView: UIView = { - let view = UIView() - view.backgroundColor = .clear - let titleLab = UILabel() - titleLab.text = "行程详情" - titleLab.font = .systemFont(ofSize: 14, weight: .medium) - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - view.addSubview(titleLab) - titleLab.layoutChain.left(15).centerY() - view.addSubview(addBtn) - addBtn.layoutChain.right(15).centerY().width(18).height(18) - return view - }() - + + /// 兼容旧绑定:标题旁 + 已移除,保留空按钮避免崩溃 lazy var addBtn: UIButton = { let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "Schedule/add"), for: .normal) - btn.backgroundColor = .clear - btn.extendEdgeInsets = UIEdgeInsets(top: 30, left: 30, bottom: 10, right: 15) + btn.isHidden = true return btn }() - + lazy var scrollView: UIScrollView = { let view = UIScrollView() - view.backgroundColor = .white + view.backgroundColor = .clear view.showsHorizontalScrollIndicator = false view.bounces = false view.delaysContentTouches = false - + let contentView = UIView() contentView.backgroundColor = .clear view.addSubview(contentView) contentView.layoutChain .edges().widthToView(view) - + contentView.addSubview(tableView) tableView.layoutChain .top() .edgesHorzontal() - + contentView.addSubview(shareGroupView) shareGroupView.layoutChain - .topToBottomOfView(tableView, offset: 5) + .topToBottomOfView(tableView, offset: 8) .edgesHorzontal() - + contentView.addSubview(createBtn) createBtn.layoutChain - .topToBottomOfView(shareGroupView, offset: 30) - .edgesHorzontal(30) + .topToBottomOfView(shareGroupView, offset: 24) + .edgesHorzontal(16) .height(50) - .bottom(kSafeBottomMargin + 10) - + .bottom(kSafeBottomMargin) + return view }() - + lazy var tableView: UITableView = { let tv = UITableView(frame: .zero, style: .plain) tv.backgroundColor = .clear tv.separatorStyle = .none - tv.estimatedRowHeight = 122 + tv.estimatedRowHeight = ScheduleTimelineMetric.pointRowHeight + tv.rowHeight = UITableView.automaticDimension tv.isScrollEnabled = false tv.showsVerticalScrollIndicator = false tv.bounces = false + tv.clipsToBounds = false tv.register(SchedulePointCell.self) + tv.register(ScheduleAddPointCell.self) return tv }() - - /// 选择分享的圈子 + lazy var shareGroupView: UIView = { let view = UIView() view.backgroundColor = .clear - + + let pin = UIImageView(image: UIImage(named: "Schedule/location")?.withRenderingMode(.alwaysTemplate)) + pin.tintColor = UIColor(hexStr: "#16B3FF") + pin.contentMode = .scaleAspectFit + view.addSubview(pin) + pin.layoutChain.left(16).top().width(14).height(14) + + let sectionLab = UILabel() + sectionLab.text = "其他" + sectionLab.font = .systemFont(ofSize: 16, weight: .bold) + sectionLab.textColor = UIColor(hexStr: "#293445") + view.addSubview(sectionLab) + sectionLab.layoutChain.leftToRightOfView(pin, offset: 6).centerY(pin) + + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 12 + view.addSubview(card) + card.layoutChain + .topToBottomOfView(sectionLab, offset: 12) + .edgesHorzontal(16) + .bottom() + let titleLab = UILabel() - titleLab.text = "选择分享的圈子" + titleLab.text = "分享的圈子" titleLab.font = .systemFont(ofSize: 14, weight: .medium) - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - view.addSubview(titleLab) - titleLab.layoutChain.left(15).top() - - view.addSubview(tagListView) + titleLab.textColor = UIColor(hexStr: "#1A1A1A") + card.addSubview(titleLab) + titleLab.layoutChain.left(16).top(19) + + card.addSubview(tagListView) tagListView.layoutChain - .topToBottomOfView(titleLab, offset: 15) - .edgesHorzontal(15) - .bottom(15) - + .topToBottomOfView(titleLab, offset: 12) + .edgesHorzontal(16) + .bottom(12) + return view }() - + lazy var tagListView: TagListView = { let view = TagListView() view.textFont = UIFont.systemFont(ofSize: 12, weight: .medium) - view.textColor = UIColor(hexStr: "#999999") - view.tagBackgroundColor = UIColor(hexStr: "#F2F2F2") - view.selectedTextColor = UIColor(hexStr: "#16B3FF") + view.textColor = UIColor(hexStr: "#AAAAAA") + view.tagBackgroundColor = UIColor(hexStr: "#F0F0F0") + view.tagBorderColor = UIColor.clear + view.selectedTextColor = UIColor(hexStr: "#00ADFE") view.tagSelectedBackgroundColor = UIColor(hexStr: "#E3F6FF") view.selectedBorderColor = UIColor(hexStr: "#16B3FF") - view.paddingX = 15 // 水平内边距 - view.paddingY = 10 // 垂直内边距 - view.alignment = .left // 对齐 + view.paddingX = 15 + view.paddingY = 10 + view.alignment = .left view.translatesAutoresizingMaskIntoConstraints = false return view }() - + lazy var createBtn: UIButton = { let btn = UIButton(type: .custom) - btn.setTitle("立即创建", for: .normal) + btn.setTitle("发布行程", for: .normal) btn.setTitleColor(.white, for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + btn.titleLabel?.font = FontManager.boboBold(18) btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) - btn.cornerRadius = 25 + btn.cornerRadius = 16 return btn }() - + override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = .white - // 使用原生圆角避免 CAShapeLayer mask 隐式动画导致的弹跳 + backgroundColor = UIColor(hexStr: "#FAFAFA") layer.cornerRadius = 30 layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] layer.masksToBounds = true setupUI() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } +// MARK: - Timeline metrics + +enum ScheduleTimelineMetric { + /// marker left 16 + width 22 / 2 + static let centerX: CGFloat = 27 + static let markerSize: CGFloat = 18 + /// 8 + 10+36+8+36+8+36+10 + 8,需与卡片内部约束严格一致,否则会冲突 + static let pointRowHeight: CGFloat = 160 + static let addRowHeight: CGFloat = 60 +} // MARK: - SchedulePointCell -class SchedulePointCell: UITableViewCell { +final class SchedulePointCell: UITableViewCell { + + static let reuseId = "SchedulePointCell" var disposeBag = DisposeBag() var onLocationTap: (() -> Void)? var onTimeTap: (() -> Void)? - var onRemarkChanged: ((String) -> Void)? + var onDelete: (() -> Void)? + /// 拖拽手柄长按手势(began/changed/ended),由 VC 处理跟手排序 + var onDragGesture: ((UILongPressGestureRecognizer) -> Void)? - func configure(item: SchedulePointItem, index: Int, total: Int, onDelete: @escaping () -> Void) { - indexLabel.text = "\(index + 1)" - locationLabel.text = item.street.isEmpty ? "点击选择地点" : item.street - remarkTF.text = item.remark + private let dashLineView = ScheduleDashLineView() + private let markerView = UIView() + private let markerLab = UILabel() + private let middleDot = UIView() - locationLabel.rx.tapGesture - .when(.recognized) - .subscribe(onNext: { [weak self] _ in - self?.onLocationTap?() - }) - .disposed(by: disposeBag) + private let cardView = UIView() + private let locationRow = ScheduleFieldRowView( + icon: UIImage(named: "Schedule/location"), + placeholder: "请选择" + ) + private let timeRow = ScheduleFieldRowView( + icon: UIImage(named: "Schedule/clock"), + placeholder: "请选择" + ) + private let remarkRow = ScheduleFieldRowView( + icon: UIImage(named: "Schedule/edit"), + placeholder: "在此添加备注", + editable: true + ) - if let expectedTime = item.expectedTime { - timeLabel.text = formatTime(expectedTime) - } else { - timeLabel.text = "请选择到达时间" + private let dragBtn = UIButton(type: .custom) + private let deleteBtn = UIButton(type: .custom) + private var locationRowRightConstraint: NSLayoutConstraint? + private var remarkRowRightConstraint: NSLayoutConstraint? + private var dragPress: UILongPressGestureRecognizer? + private var dashTopToContent: NSLayoutConstraint? + private var dashTopToMarkerCenter: NSLayoutConstraint? + private var dashBottomToContent: NSLayoutConstraint? + private var dashBottomToMarkerCenter: NSLayoutConstraint? + + func configure(item: SchedulePointItem, role: SchedulePointRole) { + switch role { + case .start: + markerView.isHidden = false + middleDot.isHidden = true + markerView.backgroundColor = UIColor(hexStr: "#16B3FF") + markerLab.text = "始" + markerLab.isHidden = false + dragBtn.isHidden = true + deleteBtn.isHidden = true + locationRowRightConstraint?.constant = -10 + remarkRowRightConstraint?.constant = -10 + // 虚线从始圆点开始往下 + dashTopToContent?.isActive = false + dashTopToMarkerCenter?.isActive = true + dashBottomToMarkerCenter?.isActive = false + dashBottomToContent?.isActive = true + case .middle: + markerView.isHidden = true + middleDot.isHidden = false + markerLab.isHidden = true + dragBtn.isHidden = false + deleteBtn.isHidden = false + locationRowRightConstraint?.constant = -40 + remarkRowRightConstraint?.constant = -40 + // 虚线贯穿整行 + dashTopToMarkerCenter?.isActive = false + dashTopToContent?.isActive = true + dashBottomToMarkerCenter?.isActive = false + dashBottomToContent?.isActive = true + case .end: + markerView.isHidden = false + middleDot.isHidden = true + markerView.backgroundColor = UIColor(hexStr: "#FF5A5A") + markerLab.text = "终" + markerLab.isHidden = false + dragBtn.isHidden = true + deleteBtn.isHidden = true + locationRowRightConstraint?.constant = -10 + remarkRowRightConstraint?.constant = -10 + // 虚线到终圆点结束 + dashTopToMarkerCenter?.isActive = false + dashTopToContent?.isActive = true + dashBottomToContent?.isActive = false + dashBottomToMarkerCenter?.isActive = true } - timeLabel.rx.tapGesture - .when(.recognized) - .subscribe(onNext: { [weak self] _ in - self?.onTimeTap?() - }) - .disposed(by: disposeBag) - - // 左侧竖线:仅有一条时不显示虚线,首条顶部不画、末条底部不画 - topDashView.isHidden = total <= 1 || index == 0 - bottomDashView.isHidden = total <= 1 || index == total - 1 - // 删除 - deleteBtn.rx.tap - .subscribe(onNext: { onDelete() }) - .disposed(by: disposeBag) + let locationText = item.street.isEmpty ? (item.locationName.isEmpty ? nil : item.locationName) : item.street + locationRow.setText(locationText) + if let expectedTime = item.expectedTime { + timeRow.setText(expectedTime.toFormat("HH:mm")) + } else { + timeRow.setText(nil) + } + remarkRow.setText(item.remark.isEmpty ? nil : item.remark) + remarkRow.textField?.text = item.remark + + locationRow.onTap = { [weak self] in self?.onLocationTap?() } + timeRow.onTap = { [weak self] in self?.onTimeTap?() } } - private func formatTime(_ date: Date) -> String { - date.toFormat("HH:mm") + var remarkText: String { + remarkRow.textField?.text ?? "" } - // MARK: - Init - - @objc private func remarkDidChange() { - onRemarkChanged?(remarkTF.text ?? "") - } + var remarkTextField: UITextField? { remarkRow.textField } override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear + contentView.backgroundColor = .clear + clipsToBounds = false + contentView.clipsToBounds = false setupViews() } @@ -287,166 +381,291 @@ class SchedulePointCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() disposeBag = DisposeBag() + onLocationTap = nil + onTimeTap = nil + onDelete = nil + onDragGesture = nil } - + private func setupViews() { - contentView.addSubview(topDashView) - contentView.addSubview(pointIcon) - contentView.addSubview(bottomDashView) - + // 先加虚线,再加圆点盖在上面 + contentView.addSubview(dashLineView) + contentView.addSubview(markerView) + contentView.addSubview(middleDot) contentView.addSubview(cardView) - cardView.addSubview(cardCornerView) - cardCornerView.addSubview(indexLabel) - cardCornerView.addSubview(locationLabel) - cardCornerView.addSubview(timeLabel) - cardCornerView.addSubview(remarkTF) - cardCornerView.addSubview(deleteBtn) + markerView.addSubview(markerLab) - topDashView.layoutChain - .top().width(1) + cardView.addSubview(locationRow) + cardView.addSubview(timeRow) + cardView.addSubview(remarkRow) + cardView.addSubview(dragBtn) + cardView.addSubview(deleteBtn) - pointIcon.layoutChain + markerView.layer.cornerRadius = ScheduleTimelineMetric.markerSize / 2 + markerView.clipsToBounds = true + markerLab.textColor = .white + markerLab.font = FontManager.youSheBiaoTiHei(11) + markerLab.textAlignment = .center + + middleDot.backgroundColor = UIColor(hexStr: "#293445") + middleDot.layer.cornerRadius = 5 + let middleCenter = UIView() + middleCenter.backgroundColor = .white + middleCenter.cornerRadius = 2 + middleDot.addSubview(middleCenter) + middleCenter.layoutChain + .centerX() .centerY() - .left(22) - .width(18).height(18) - - topDashView.layoutChain - .centerX(pointIcon) - .bottomToTopOfView(pointIcon, offset: -10) + .width(4) + .heightToWidth(1) - bottomDashView.layoutChain - .topToBottomOfView(pointIcon, offset: 10) - .centerX(pointIcon) - .width(1) - .bottom() + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 16 + + dragBtn.setImage(UIImage(named: "Schedule/drag")?.withRenderingMode(.alwaysOriginal), for: .normal) + dragBtn.extendEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) + + deleteBtn.setImage(UIImage(named: "Schedule/delete")?.withRenderingMode(.alwaysOriginal), for: .normal) + deleteBtn.extendEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) + deleteBtn.addTarget(self, action: #selector(tapDelete), for: .touchUpInside) + + let press = UILongPressGestureRecognizer(target: self, action: #selector(handleDragPress(_:))) + press.minimumPressDuration = 0.12 + dragBtn.addGestureRecognizer(press) + dragPress = press + + dashLineView.translatesAutoresizingMaskIntoConstraints = false + markerView.translatesAutoresizingMaskIntoConstraints = false + + let topToContent = dashLineView.topAnchor.constraint(equalTo: contentView.topAnchor) + let topToMarker = dashLineView.topAnchor.constraint(equalTo: markerView.centerYAnchor) + let bottomToContent = dashLineView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + let bottomToMarker = dashLineView.bottomAnchor.constraint(equalTo: markerView.centerYAnchor) + dashTopToContent = topToContent + dashTopToMarkerCenter = topToMarker + dashBottomToContent = bottomToContent + dashBottomToMarkerCenter = bottomToMarker + // 默认中间点:贯穿;configure 时再按 role 切换 + topToMarker.isActive = false + bottomToMarker.isActive = false + + NSLayoutConstraint.activate([ + topToContent, + bottomToContent, + dashLineView.centerXAnchor.constraint(equalTo: contentView.leftAnchor, constant: ScheduleTimelineMetric.centerX), + dashLineView.widthAnchor.constraint(equalToConstant: 1), + + markerView.centerXAnchor.constraint(equalTo: contentView.leftAnchor, constant: ScheduleTimelineMetric.centerX), + markerView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + markerView.widthAnchor.constraint(equalToConstant: ScheduleTimelineMetric.markerSize), + markerView.heightAnchor.constraint(equalToConstant: ScheduleTimelineMetric.markerSize) + ]) + + markerLab.layoutChain.edges() + + middleDot.layoutChain + .centerX(markerView) + .centerY(markerView) + .width(10) + .height(10) - // 右侧卡片 cardView.layoutChain - .top(15) - .leftToRightOfView(pointIcon, offset: 20) - .right(15) -// .height(92) - .bottom(15) + .top(8) + .left(50) + .right(16) + .bottom(8) - cardCornerView.layoutChain.edges() - - indexLabel.layoutChain - .top().left() - .width(30).height(20) - - locationLabel.layoutChain - .topToBottomOfView(indexLabel, offset: 10) + // 上:时间,中:地点,下:备注 + timeRow.layoutChain + .top(10) .left(10) - .compressionHorizontal(.defaultLow) - - timeLabel.layoutChain - .topToView(locationLabel) .right(10) - .compressionHorizontal(.required) + .height(36) + + locationRow.translatesAutoresizingMaskIntoConstraints = false + remarkRow.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + locationRow.topAnchor.constraint(equalTo: timeRow.bottomAnchor, constant: 8), + locationRow.leftAnchor.constraint(equalTo: cardView.leftAnchor, constant: 10), + locationRow.heightAnchor.constraint(equalToConstant: 36), + remarkRow.topAnchor.constraint(equalTo: locationRow.bottomAnchor, constant: 8), + remarkRow.leftAnchor.constraint(equalTo: cardView.leftAnchor, constant: 10), + remarkRow.heightAnchor.constraint(equalToConstant: 36), + remarkRow.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -10) + ]) + let locationRight = locationRow.rightAnchor.constraint(equalTo: cardView.rightAnchor, constant: -10) + let remarkRight = remarkRow.rightAnchor.constraint(equalTo: cardView.rightAnchor, constant: -10) + locationRight.isActive = true + remarkRight.isActive = true + locationRowRightConstraint = locationRight + remarkRowRightConstraint = remarkRight + + dragBtn.layoutChain + .right(10) + .centerY(locationRow) + .width(22) + .height(22) - locationLabel.layoutChain.rightToLeftOfView(timeLabel, offset: -8) - deleteBtn.layoutChain - .right(15) - .width(14).height(14) - - remarkTF.layoutChain - .topToBottomOfView(locationLabel, offset: 15) - .leftToView(locationLabel) - .rightToLeftOfView(deleteBtn, offset: -10) - .bottom(15) - - deleteBtn.layoutChain.centerY(remarkTF) + .right(12) + .centerY(remarkRow) + .width(18) + .height(18) } - // MARK: - Views - private let topDashView: DashLineView = { - let v = DashLineView() - v.isHidden = true - v.backgroundColor = .clear - return v - }() + @objc private func tapDelete() { + onDelete?() + } - private let pointIcon: UIImageView = { - let iv = UIImageView(image: UIImage(named: "Schedule/point")) - iv.contentMode = .scaleAspectFit - return iv - }() - - private let bottomDashView: DashLineView = { - let v = DashLineView() - v.isHidden = true - v.backgroundColor = .clear - return v - }() - - private let cardView: UIView = { - let view = UIView() - view.backgroundColor = .clear - view.layer.shadowColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.05).cgColor - view.layer.shadowOffset = CGSize(width: 0, height: 0) - view.layer.shadowOpacity = 1 - view.layer.shadowRadius = 8 - return view - }() - - lazy var cardCornerView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.cornerRadius = 10 - return view - }() - - private let indexLabel: UILabel = { - let l = UILabel() - l.backgroundColor = UIColor(hexStr: "#DCF4FF") - l.textColor = UIColor(hexStr: "#176F9B") - l.font = .systemFont(ofSize: 12, weight: .medium) - l.textAlignment = .center - return l - }() - - private let locationLabel: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 14, weight: .medium) - l.textColor = UIColor(hexStr: "#333333") -// l.numberOfLines = 0 - return l - }() - - private let timeLabel: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 12, weight: .medium) - l.textColor = UIColor(hexStr: "#333333") - l.isUserInteractionEnabled = true - l.textAlignment = .right - return l - }() - - lazy var remarkTF: UITextField = { - let tf = UITextField() - tf.font = .systemFont(ofSize: 12) - tf.placeholder = "在此添加备注..." - tf.returnKeyType = .done - return tf - }() - - private let deleteBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "Schedule/delete"), for: .normal) - btn.extendEdgeInsets = UIEdgeInsets(top: 10, left: 15, bottom: 15, right: 15) - return btn - }() - - override func layoutSubviews() { - super.layoutSubviews() - cardCornerView.layoutIfNeeded() - indexLabel.setCornerRadius(corners: [.bottomRight], withCornerRadii: CGSize(width: 10, height: 10)) + @objc private func handleDragPress(_ gesture: UILongPressGestureRecognizer) { + onDragGesture?(gesture) } } -/// 虚线绘制视图(竖线) -class DashLineView: UIView { +// MARK: - ScheduleAddPointCell + +final class ScheduleAddPointCell: UITableViewCell { + static let reuseId = "ScheduleAddPointCell" + + var onAdd: (() -> Void)? + + private let dashLineView = ScheduleDashLineView() + private let addBtn = UIButton(type: .custom) + + override init(style: CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + clipsToBounds = false + contentView.clipsToBounds = false + setup() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func setup() { + contentView.addSubview(dashLineView) + contentView.addSubview(addBtn) + + addBtn.setTitle(" 添加行程", for: .normal) + addBtn.setTitleColor(UIColor(hexStr: "#16B3FF"), for: .normal) + addBtn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + addBtn.setImage(UIImage(named: "Schedule/add")?.withRenderingMode(.alwaysOriginal), for: .normal) + addBtn.backgroundColor = UIColor(hexStr: "#F6FBFF") + addBtn.layer.cornerRadius = 12 + addBtn.addTarget(self, action: #selector(tapAdd), for: .touchUpInside) + + dashLineView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + dashLineView.topAnchor.constraint(equalTo: contentView.topAnchor), + dashLineView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + dashLineView.centerXAnchor.constraint(equalTo: contentView.leftAnchor, constant: ScheduleTimelineMetric.centerX), + dashLineView.widthAnchor.constraint(equalToConstant: 1) + ]) + + addBtn.layoutChain + .top(8) + .left(50) + .right(16) + .height(44) + .bottom(8) + } + + private var dashBorder: CAShapeLayer? + + override func layoutSubviews() { + super.layoutSubviews() + dashBorder?.removeFromSuperlayer() + let border = CAShapeLayer() + border.strokeColor = UIColor(hexStr: "#16B3FF").cgColor + border.fillColor = nil + border.lineDashPattern = [5, 4] + border.lineWidth = 1 + border.frame = addBtn.bounds + border.path = UIBezierPath(roundedRect: addBtn.bounds, cornerRadius: 12).cgPath + addBtn.layer.borderWidth = 0 + addBtn.layer.addSublayer(border) + dashBorder = border + } + + @objc private func tapAdd() { + onAdd?() + } +} + +// MARK: - Field row + +private final class ScheduleFieldRowView: UIView { + var onTap: (() -> Void)? + private(set) var textField: UITextField? + + private let iconView = UIImageView() + private let label = UILabel() + private let placeholder: String + + init(icon: UIImage?, placeholder: String, editable: Bool = false) { + self.placeholder = placeholder + super.init(frame: .zero) + backgroundColor = .white + layer.cornerRadius = 8 + + iconView.image = icon?.withRenderingMode(.alwaysOriginal) + iconView.contentMode = .scaleAspectFit + addSubview(iconView) + iconView.layoutChain.left(10).centerY().width(14).height(14) + + let tfView = UIView() + tfView.backgroundColor = UIColor(hexStr: "#FAFAFA") + tfView.cornerRadius = 8 + addSubview(tfView) + tfView.layoutChain.leftToRightOfView(iconView, offset: 8).right(10).centerY().height(34) + + if editable { + let tf = UITextField() + tf.font = .systemFont(ofSize: 13, weight: .medium) + tf.textColor = UIColor(hexStr: "#333333") + tf.placeholder = placeholder + tf.returnKeyType = .done + tfView.addSubview(tf) + tf.layoutChain.edgesHorzontal(10).edgesVertical() + textField = tf + } else { + label.font = .systemFont(ofSize: 13, weight: .medium) + label.textColor = UIColor(hexStr: "#999999") + label.text = placeholder + label.isUserInteractionEnabled = true + tfView.addSubview(label) + label.layoutChain.edgesHorzontal(10).centerY() + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tap) + } + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func setText(_ text: String?) { + if let tf = textField { + tf.text = text + return + } + if let text, !text.isEmpty { + label.text = text + label.textColor = UIColor(hexStr: "#333333") + } else { + label.text = placeholder + label.textColor = UIColor(hexStr: "#999999") + } + } + + @objc private func handleTap() { + onTap?() + } +} + +// MARK: - Dash line + +final class ScheduleDashLineView: UIView { override func layoutSubviews() { super.layoutSubviews() layer.sublayers?.forEach { if $0 is CAShapeLayer { $0.removeFromSuperlayer() } } @@ -459,15 +678,16 @@ class DashLineView: UIView { let path = UIBezierPath() path.move(to: CGPoint(x: bounds.midX, y: 0)) - path.addLine(to: CGPoint(x: bounds.midX, y: bounds.height)) + path.addLine(to: CGPoint(x: bounds.maxX == 0 ? 0.5 : bounds.midX, y: bounds.height)) shapeLayer.path = path.cgPath - layer.addSublayer(shapeLayer) } } -/// 虚线绘制视图(横线) -class HorizontalDashLineView: UIView { +/// 兼容旧命名 +typealias DashLineView = ScheduleDashLineView + +final class HorizontalDashLineView: UIView { override func layoutSubviews() { super.layoutSubviews() layer.sublayers?.forEach { if $0 is CAShapeLayer { $0.removeFromSuperlayer() } } @@ -482,7 +702,6 @@ class HorizontalDashLineView: UIView { path.move(to: CGPoint(x: 0, y: bounds.midY)) path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.midY)) shapeLayer.path = path.cgPath - layer.addSublayer(shapeLayer) } } diff --git a/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVC.swift b/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVC.swift index be65dac0..879165ab 100644 --- a/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVC.swift +++ b/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVC.swift @@ -12,12 +12,13 @@ import RxDataSources import SwiftyUserDefaults import BRPickerView import RxGesture +import CoreLocation +import TagListView +import ObjectMapper #if !targetEnvironment(simulator) import AMapNaviKit import AMapSearchKit -import TagListView -import ObjectMapper #endif class CreateScheduleVC: BaseViewController, MAMapViewDelegate { @@ -133,123 +134,79 @@ class CreateScheduleVC: BaseViewController, MAMapViewDelegate { } // MARK: - Binding - + private lazy var dataSource: RxTableViewSectionedReloadDataSource = { RxTableViewSectionedReloadDataSource( - configureCell: { [weak self] _, tv, indexPath, item in - let cell: SchedulePointCell = tv.dequeueReusableCell(for: indexPath) - - cell.configure(item: item, index: indexPath.row, - total: self?.viewModel.pointsRelay.value.count ?? 0, - onDelete: { [weak self] in - self?.viewModel.deletePointAt.onNext(indexPath.row) - }) - - // 到达时间 - cell.onTimeTap = { [weak self] in - guard let self = self else { return } - let picker = BRDatePickerView(pickerMode: .HM) - picker.title = "选择到达时间" - let style = BRPickerStyle() - style.selectRowTextColor = UIColor(hexStr: "#16B3FF") - picker.pickerStyle = style - picker.resultBlock = { date, _ in - guard let d = date else { return } - var list = self.viewModel.pointsRelay.value - guard indexPath.row < list.count else { return } - // 用今天的日期 + 选择的时间拼成完整时间戳 - let cal = Calendar.current - let today = Date() - var comps = cal.dateComponents([.year, .month, .day], from: today) - let timeComps = cal.dateComponents([.hour, .minute], from: d) - comps.hour = timeComps.hour - comps.minute = timeComps.minute - if let merged = cal.date(from: comps) { - list[indexPath.row].expectedTime = merged + configureCell: { [weak self] _, tv, indexPath, row in + switch row { + case .add: + let cell: ScheduleAddPointCell = tv.dequeueReusableCell(for: indexPath) + cell.onAdd = { [weak self] in + self?.viewModel.addPointTapped.onNext(()) + } + return cell + + case .point(let pointIndex, let item, let role): + let cell: SchedulePointCell = tv.dequeueReusableCell(for: indexPath) + cell.configure(item: item, role: role) + + cell.onDelete = { [weak self] in + self?.viewModel.deletePointAt.onNext(pointIndex) + } + + cell.onDragGesture = { [weak self] gesture in + self?.handleMiddlePointDrag(gesture, pointIndex: pointIndex, cell: cell) + } + + cell.onTimeTap = { [weak self] in + self?.showTimePicker(for: pointIndex) + } + + cell.onLocationTap = { [weak self] in + self?.showLocationPicker(for: pointIndex, item: item) + } + + cell.remarkTextField?.rx.controlEvent(.editingDidEnd) + .subscribe(onNext: { [weak self, weak cell] in + guard let self = self, let text = cell?.remarkTextField?.text else { return } + var list = self.viewModel.pointsRelay.value + guard pointIndex < list.count else { return } + list[pointIndex].remark = text self.viewModel.pointsRelay.accept(list) - } - } - picker.show() + }) + .disposed(by: cell.disposeBag) + + return cell } - // 地点 - cell.onLocationTap = { [weak self] in - guard let self = self else { return } - let coord = item.latitude != 0 || item.longitude != 0 - ? CLLocationCoordinate2D(latitude: item.latitude, longitude: item.longitude) - : kCLLocationCoordinate2DInvalid - let vc = LocationPickerVC() - vc.modalPresentationStyle = .fullScreen - if !item.locationName.isEmpty { - vc.initialLocation = PickedLocation( - name: item.locationName, address: item.address, - coordinate: coord, - province: item.province, city: item.city, - district: item.district, street: item.street, - country: item.country, formatted_address: item.formatted_address - ) - } - vc.onPickedLocation = { picked in - var list = self.viewModel.pointsRelay.value - guard indexPath.row < list.count else { return } - list[indexPath.row].locationName = picked.name - list[indexPath.row].address = picked.address - list[indexPath.row].latitude = picked.coordinate.latitude - list[indexPath.row].longitude = picked.coordinate.longitude - list[indexPath.row].province = picked.province - list[indexPath.row].city = picked.city - list[indexPath.row].district = picked.district - list[indexPath.row].street = picked.street - list[indexPath.row].country = picked.country - list[indexPath.row].formatted_address = picked.formatted_address - self.viewModel.pointsRelay.accept(list) - } - self.present(vc, animated: true) - } - - // 备注 - cell.remarkTF.rx.controlEvent(.editingDidEnd) - .subscribe(onNext: { - guard let self = self, let text = cell.remarkTF.text else { return } - var list = self.viewModel.pointsRelay.value - guard indexPath.row < list.count else { return } - list[indexPath.row].remark = text - self.viewModel.pointsRelay.accept(list) - }) - .disposed(by: cell.disposeBag) - - return cell }) }() - + fileprivate func bindViewModel() { - // 日期 viewModel.dateString + .map { $0 } .bind(to: popView.dateLab.rx.text) .disposed(by: disposeBag) - - // tableView - viewModel.pointsRelay + + viewModel.listItems .map { [SchedulePointSection(model: "", items: $0)] } .bind(to: popView.tableView.rx.items(dataSource: dataSource)) .disposed(by: disposeBag) - + + popView.tableView.rx.setDelegate(self) + .disposed(by: disposeBag) + // 动态高度 + 刷新地图路线 viewModel.pointsRelay .observe(on: MainScheduler.asyncInstance) .subscribe(onNext: { [weak self] items in guard let self = self else { return } - let h = max(CGFloat(items.count) * 129, 129) + let h = CGFloat(items.count) * ScheduleTimelineMetric.pointRowHeight + + ScheduleTimelineMetric.addRowHeight self.popView.tableView.layoutChain.height(h) self.refreshMapPoints() }) .disposed(by: disposeBag) - - // 添加 - popView.addBtn.rx.tap - .bind(to: viewModel.addPointTapped) - .disposed(by: disposeBag) - - // 日期点击 + popView.dateLab.rx.tapGesture .when(.recognized) .subscribe(onNext: { [weak self] _ in @@ -257,7 +214,6 @@ class CreateScheduleVC: BaseViewController, MAMapViewDelegate { }) .disposed(by: disposeBag) - // 创建行程 popView.createBtn.rx.tap .subscribe(onNext: { [weak self] _ in self?.handleCreate() @@ -265,6 +221,173 @@ class CreateScheduleVC: BaseViewController, MAMapViewDelegate { .disposed(by: disposeBag) } + private func showTimePicker(for pointIndex: Int) { + let picker = BRDatePickerView(pickerMode: .HM) + picker.title = "选择到达时间" + if pointIndex < viewModel.pointsRelay.value.count, + let existing = viewModel.pointsRelay.value[pointIndex].expectedTime { + picker.selectDate = existing + } + picker.pickerStyle = makeSchedulePickerStyle() + picker.pickerFooterView = makeSchedulePickerFooter { [weak picker] in + picker?.doneBlock?() + picker?.dismiss() + } + picker.resultBlock = { [weak self] date, _ in + guard let self = self, let d = date else { return } + var list = self.viewModel.pointsRelay.value + guard pointIndex < list.count else { return } + let cal = Calendar.current + var comps = cal.dateComponents([.year, .month, .day], from: self.viewModel.selectedDate.value) + let timeComps = cal.dateComponents([.hour, .minute], from: d) + comps.hour = timeComps.hour + comps.minute = timeComps.minute + if let merged = cal.date(from: comps) { + list[pointIndex].expectedTime = merged + self.viewModel.pointsRelay.accept(list) + } + } + picker.show() + } + + private func showLocationPicker(for pointIndex: Int, item: SchedulePointItem) { + let coord = item.latitude != 0 || item.longitude != 0 + ? CLLocationCoordinate2D(latitude: item.latitude, longitude: item.longitude) + : kCLLocationCoordinate2DInvalid + let vc = LocationPickerVC() + vc.modalPresentationStyle = .fullScreen + if !item.locationName.isEmpty { + vc.initialLocation = PickedLocation( + name: item.locationName, address: item.address, + coordinate: coord, + province: item.province, city: item.city, + district: item.district, street: item.street, + country: item.country, formatted_address: item.formatted_address + ) + } + vc.onPickedLocation = { [weak self] picked in + guard let self = self else { return } + var list = self.viewModel.pointsRelay.value + guard pointIndex < list.count else { return } + list[pointIndex].locationName = picked.name + list[pointIndex].address = picked.address + list[pointIndex].latitude = picked.coordinate.latitude + list[pointIndex].longitude = picked.coordinate.longitude + list[pointIndex].province = picked.province + list[pointIndex].city = picked.city + list[pointIndex].district = picked.district + list[pointIndex].street = picked.street + list[pointIndex].country = picked.country + list[pointIndex].formatted_address = picked.formatted_address + self.viewModel.pointsRelay.accept(list) + } + present(vc, animated: true) + } + + // MARK: - Middle point drag reorder + + private var dragSnapshot: UIView? + private var dragSourcePointIndex: Int? + private var dragTargetPointIndex: Int? + private weak var dragSourceCell: SchedulePointCell? + + private func handleMiddlePointDrag(_ gesture: UILongPressGestureRecognizer, + pointIndex: Int, + cell: SchedulePointCell) { + let points = viewModel.pointsRelay.value + guard points.count > 2, + pointIndex > 0, + pointIndex < points.count - 1 else { return } + + let table = popView.tableView + let locationInTable = gesture.location(in: table) + + switch gesture.state { + case .began: + guard let snapshot = cell.snapshotView(afterScreenUpdates: true) else { return } + let frameInPop = table.convert(cell.frame, to: popView) + snapshot.frame = frameInPop + snapshot.alpha = 0.92 + snapshot.layer.shadowColor = UIColor.black.cgColor + snapshot.layer.shadowOpacity = 0.2 + snapshot.layer.shadowRadius = 6 + snapshot.layer.shadowOffset = CGSize(width: 0, height: 2) + popView.addSubview(snapshot) + dragSnapshot = snapshot + dragSourcePointIndex = pointIndex + dragTargetPointIndex = pointIndex + dragSourceCell = cell + popView.isReorderingPoints = true + cell.alpha = 0.15 + UIView.animate(withDuration: 0.12) { + snapshot.transform = CGAffineTransform(scaleX: 1.02, y: 1.02) + } + + case .changed: + guard let snapshot = dragSnapshot else { return } + let locationInPop = gesture.location(in: popView) + snapshot.center = CGPoint(x: snapshot.center.x, y: locationInPop.y) + + // 拖动过程只记录目标,松手再改数据,避免 reload 打断手势 + if let targetPath = table.indexPathForRow(at: locationInTable) { + let items = CreateScheduleVM.buildListItems(from: viewModel.pointsRelay.value) + if targetPath.row < items.count, + case .point(let targetPointIndex, _, .middle) = items[targetPath.row] { + dragTargetPointIndex = targetPointIndex + } + } + + // 内容较多时,靠近边缘自动滚一下外层 scroll + autoScrollPopIfNeeded(locationInPop: locationInPop) + + case .ended, .cancelled, .failed: + if gesture.state == .ended, + let from = dragSourcePointIndex, + let to = dragTargetPointIndex, + from != to { + viewModel.movePoint.onNext((from: from, to: to)) + } + finishMiddlePointDrag() + + default: + break + } + } + + private func autoScrollPopIfNeeded(locationInPop: CGPoint) { + let scroll = popView.scrollView + let edge: CGFloat = 60 + let maxY = popView.bounds.height - edge + var offset = scroll.contentOffset + if locationInPop.y < edge { + offset.y = max(0, offset.y - 8) + scroll.setContentOffset(offset, animated: false) + } else if locationInPop.y > maxY { + let maxOffset = max(0, scroll.contentSize.height - scroll.bounds.height) + offset.y = min(maxOffset, offset.y + 8) + scroll.setContentOffset(offset, animated: false) + } + } + + private func finishMiddlePointDrag() { + let snapshot = dragSnapshot + let sourceCell = dragSourceCell + dragSnapshot = nil + dragSourcePointIndex = nil + dragTargetPointIndex = nil + dragSourceCell = nil + popView.isReorderingPoints = false + + UIView.animate(withDuration: 0.18, animations: { + snapshot?.alpha = 0 + snapshot?.transform = .identity + }, completion: { _ in + snapshot?.removeFromSuperview() + }) + sourceCell?.alpha = 1 + popView.tableView.visibleCells.forEach { $0.alpha = 1 } + } + private func handleCreate() { let points = viewModel.pointsRelay.value let hasLocation = points.filter { $0.latitude != 0 || $0.longitude != 0 } @@ -311,20 +434,80 @@ class CreateScheduleVC: BaseViewController, MAMapViewDelegate { #endif } - // MARK: - Date Picker + // MARK: - Date / Time Picker + private func showDatePicker() { let picker = BRDatePickerView(pickerMode: .YMD) + picker.title = "选择日期" picker.minDate = Date() picker.maxDate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) - let style = BRPickerStyle() - style.selectRowTextColor = UIColor(hexStr: "#16B3FF") - picker.pickerStyle = style + picker.selectDate = viewModel.selectedDate.value + picker.pickerStyle = makeSchedulePickerStyle() + picker.pickerFooterView = makeSchedulePickerFooter { [weak picker] in + picker?.doneBlock?() + picker?.dismiss() + } picker.resultBlock = { [weak self] date, _ in if let d = date { self?.viewModel.selectedDate.accept(d) } } picker.show() } - + + private func makeSchedulePickerStyle() -> BRPickerStyle { + let style = BRPickerStyle() + let width = UIScreen.main.bounds.width + + style.topCornerRadius = 20 + style.hiddenShadowLine = true + style.hiddenTitleLine = true + style.hiddenDoneBtn = true + style.titleBarHeight = 56 + style.titleBarColor = .white + style.alertViewColor = .white + style.maskColor = UIColor.black.withAlphaComponent(0.4) + + style.titleTextColor = UIColor(hexStr: "#293445") + style.titleTextFont = .systemFont(ofSize: 18, weight: .semibold) + style.titleLabelFrame = CGRect(x: 0, y: 0, width: 150, height: 56) + + style.cancelBtnImage = UIImage(named: "Common/x_black") + style.cancelBtnTitle = "" + style.cancelColor = UIColor(hexStr: "#F2F2F2") + style.cancelCornerRadius = 14 + style.cancelBorderStyle = .fill + style.cancelBtnFrame = CGRect(x: width - 16 - 28, y: 14, width: 28, height: 28) + + style.pickerColor = .white + style.pickerHeight = 230 + style.rowHeight = 44 + style.clearPickerNewStyle = true + style.selectRowColor = UIColor(hexStr: "#E3F6FF") + style.selectRowTextColor = UIColor(hexStr: "#16B3FF") + style.selectRowTextFont = .systemFont(ofSize: 18, weight: .medium) + style.pickerTextColor = UIColor(hexStr: "#333333") + style.pickerTextFont = .systemFont(ofSize: 16, weight: .regular) + + return style + } + + private func makeSchedulePickerFooter(onDone: @escaping () -> Void) -> UIView { + let width = UIScreen.main.bounds.width + let footer = UIView(frame: CGRect(x: 0, y: 0, width: width, height: 78)) + footer.backgroundColor = .white + + let btn = UIButton(type: .custom) + btn.setTitle("确定", for: .normal) + btn.setTitleColor(.white, for: .normal) + btn.titleLabel?.font = FontManager.boboBold(18) + btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + btn.layer.cornerRadius = 20 + btn.clipsToBounds = true + btn.frame = CGRect(x: 30, y: 8, width: width - 60, height: 56) + btn.addAction(UIAction { _ in onDone() }, for: .touchUpInside) + footer.addSubview(btn) + return footer + } + // MARK: - 路线规划 #if !targetEnvironment(simulator) private let routeSearch = AMapSearchAPI() @@ -455,6 +638,20 @@ class CreateScheduleVC: BaseViewController, MAMapViewDelegate { } } +// MARK: - UITableViewDelegate (row height) +extension CreateScheduleVC: UITableViewDelegate { + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let items = CreateScheduleVM.buildListItems(from: viewModel.pointsRelay.value) + guard indexPath.row < items.count else { return ScheduleTimelineMetric.pointRowHeight } + switch items[indexPath.row] { + case .add: + return ScheduleTimelineMetric.addRowHeight + case .point: + return ScheduleTimelineMetric.pointRowHeight + } + } +} + // MARK: - TagListViewDelegate extension CreateScheduleVC: TagListViewDelegate { func tagPressed(_ title: String, tagView: TagView, sender: TagListView) { diff --git a/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVM.swift b/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVM.swift index 52416615..700a84c2 100644 --- a/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVM.swift +++ b/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVM.swift @@ -27,17 +27,38 @@ struct SchedulePointItem: IdentifiableType, Equatable { var remark: String = "" } -typealias SchedulePointSection = SectionModel +enum SchedulePointRole: Equatable { + case start + case middle + case end +} + +enum CreateScheduleListItem: IdentifiableType, Equatable { + case point(index: Int, item: SchedulePointItem, role: SchedulePointRole) + case add + + var identity: String { + switch self { + case .point(_, let item, _): + return item.identity + case .add: + return "__add__" + } + } +} + +typealias SchedulePointSection = SectionModel class CreateScheduleVM { let disposeBag = DisposeBag() let scheduModel: ScheduleModel? - + // MARK: - Input let addPointTapped = PublishSubject() let deletePointAt = PublishSubject() + let movePoint = PublishSubject<(from: Int, to: Int)>() let updatePoint = PublishSubject<(index: Int, item: SchedulePointItem)>() let selectedDate = BehaviorRelay(value: Date()) var selectedGroupKeys: [String] = [] @@ -58,55 +79,102 @@ class CreateScheduleVM { // MARK: - Output let pointsRelay: BehaviorRelay<[SchedulePointItem]> + let listItems: Observable<[CreateScheduleListItem]> let dateString: Observable init(_ model: ScheduleModel?) { self.scheduModel = model - // 编辑模式:从 model 回显数据 if let m = model, !m.points.isEmpty { - pointsRelay = BehaviorRelay<[SchedulePointItem]>(value: m.points.map { $0.toSchedulePointItem() }) + var items = m.points.map { $0.toSchedulePointItem() } + while items.count < 2 { + items.append(SchedulePointItem()) + } + pointsRelay = BehaviorRelay<[SchedulePointItem]>(value: items) selectedDate.accept(Date(timeIntervalSince1970: TimeInterval(m.timestamp) / 1000)) selectedGroupKeys = m.groups.map { $0.group_key } } else { - pointsRelay = BehaviorRelay<[SchedulePointItem]>(value: [SchedulePointItem()]) + pointsRelay = BehaviorRelay<[SchedulePointItem]>(value: [SchedulePointItem(), SchedulePointItem()]) } dateString = selectedDate .map { $0.toFormat("yyyy年MM月dd日") } - // 添加行程点 + listItems = pointsRelay.map { Self.buildListItems(from: $0) } + + // 添加行程点:插入到终点之前 addPointTapped .withLatestFrom(pointsRelay) .map { list in var new = list - new.append(SchedulePointItem()) + let insertAt = max(new.count - 1, 1) + new.insert(SchedulePointItem(), at: insertAt) return new } .bind(to: pointsRelay) .disposed(by: disposeBag) - // 删除行程点 + // 删除行程点:仅允许删中间点,且始终保留始+终 deletePointAt .withLatestFrom(pointsRelay) { ($1, $0) } - .subscribe(onNext: { list, idx in - guard list.count > 1, idx < list.count else { return } + .subscribe(onNext: { [weak self] list, idx in + guard list.count > 2, idx > 0, idx < list.count - 1 else { return } var new = list new.remove(at: idx) - self.pointsRelay.accept(new) + self?.pointsRelay.accept(new) + }) + .disposed(by: disposeBag) + + // 中间点排序(始/终固定) + movePoint + .withLatestFrom(pointsRelay) { ($1, $0) } + .subscribe(onNext: { [weak self] list, move in + guard list.count > 2 else { return } + let from = move.from + let to = move.to + let middleRange = 1..<(list.count - 1) + guard middleRange.contains(from), middleRange.contains(to), from != to else { return } + var new = list + let item = new.remove(at: from) + new.insert(item, at: to) + self?.pointsRelay.accept(new) }) .disposed(by: disposeBag) // 更新某个行程点 updatePoint .withLatestFrom(pointsRelay) { ($1, $0) } - .subscribe(onNext: { list, update in + .subscribe(onNext: { [weak self] list, update in guard update.index < list.count else { return } var new = list new[update.index] = update.item - self.pointsRelay.accept(new) + self?.pointsRelay.accept(new) }) .disposed(by: disposeBag) } + static func buildListItems(from points: [SchedulePointItem]) -> [CreateScheduleListItem] { + guard points.count >= 2 else { + return points.enumerated().map { idx, item in + let role: SchedulePointRole = idx == 0 ? .start : .end + return .point(index: idx, item: item, role: role) + } + } + var rows: [CreateScheduleListItem] = [] + rows.append(.point(index: 0, item: points[0], role: .start)) + if points.count > 2 { + for i in 1..<(points.count - 1) { + rows.append(.point(index: i, item: points[i], role: .middle)) + } + } + rows.append(.add) + rows.append(.point(index: points.count - 1, item: points[points.count - 1], role: .end)) + return rows + } + + static func role(at index: Int, total: Int) -> SchedulePointRole { + if index == 0 { return .start } + if index == total - 1 { return .end } + return .middle + } } diff --git a/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleView.swift b/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleView.swift index 086818a2..184b0f83 100644 --- a/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleView.swift +++ b/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleView.swift @@ -72,6 +72,7 @@ class CreateScheduleView: UIView { @objc private func handlePopPan(_ pan: UIPanGestureRecognizer) { guard isLimitsSet, let topConstraint = popTopConstraint else { return } + if createSchedulePopView.isReorderingPoints { return } switch pan.state { case .began: @@ -230,6 +231,15 @@ extension CreateScheduleView: UIScrollViewDelegate { extension CreateScheduleView: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { + if createSchedulePopView.isReorderingPoints { return false } + return true + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if createSchedulePopView.isReorderingPoints, + gestureRecognizer is UIPanGestureRecognizer { + return false + } return true } }