diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index fd77b196..e5da6482 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ 305A76AD2FCA8C7000227D26 /* UIApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A74F62FCA8C7000227D26 /* UIApplicationExtension.swift */; }; 305A76AE2FCA8C7000227D26 /* UIButton+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A74F72FCA8C7000227D26 /* UIButton+Extension.swift */; }; 305A76AF2FCA8C7000227D26 /* UIColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A74F82FCA8C7000227D26 /* UIColor+Extension.swift */; }; + 55B217A33022F0B100784706 /* UIDevice+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217A33022F0B100784707 /* UIDevice+Extension.swift */; }; 305A76B02FCA8C7000227D26 /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A74F92FCA8C7000227D26 /* UIFont+Extension.swift */; }; 305A76B12FCA8C7000227D26 /* UIImage+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A74FA2FCA8C7000227D26 /* UIImage+Extension.swift */; }; 305A76B22FCA8C7000227D26 /* UIImage+Resource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A74FB2FCA8C7000227D26 /* UIImage+Resource.swift */; }; @@ -186,6 +187,11 @@ 30A87A642FEE75520095E7C6 /* CreateBubbleTipsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A632FEE75520095E7C6 /* CreateBubbleTipsView.swift */; }; 30A87A662FEE843E0095E7C6 /* CreateBubbleDoneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A652FEE843E0095E7C6 /* CreateBubbleDoneView.swift */; }; 30A87A682FEE86560095E7C6 /* CreateBubblePopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A672FEE86560095E7C6 /* CreateBubblePopView.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 */; }; + 55C219C13024C00100784731 /* SearchLocationHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55C219C13024C00100784730 /* SearchLocationHeaderView.swift */; }; 30A87A6B2FEF5B950095E7C6 /* SearchLocationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A6A2FEF5B950095E7C6 /* SearchLocationView.swift */; }; 30A87A6D2FEF5BA10095E7C6 /* SearchLocationVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A6C2FEF5BA10095E7C6 /* SearchLocationVC.swift */; }; 30A87A6F2FEF7BE40095E7C6 /* SearchLocationResultVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A6E2FEF7BE40095E7C6 /* SearchLocationResultVC.swift */; }; @@ -233,6 +239,8 @@ 30D74AAB2FE8C7700050EB2C /* GPSSignalHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74AAA2FE8C7700050EB2C /* GPSSignalHelper.swift */; }; 30D74AAE2FEA13E00050EB2C /* ScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74AAD2FEA13E00050EB2C /* ScheduleView.swift */; }; 30D74AB02FEA13ED0050EB2C /* ScheduleVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74AAF2FEA13ED0050EB2C /* ScheduleVC.swift */; }; + 55B218B13024B00100784722 /* FeatureIntroVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218B13024B00100784721 /* FeatureIntroVC.swift */; }; + 55B218B13024B00100784724 /* FeatureIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218B13024B00100784723 /* FeatureIntroView.swift */; }; 30D74AB22FEA1D5D0050EB2C /* ScheduleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74AB12FEA1D5D0050EB2C /* ScheduleViewModel.swift */; }; 30D74AB42FEA25B90050EB2C /* ViewedModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74AB32FEA25B90050EB2C /* ViewedModel.swift */; }; 30D74AB62FEA34FF0050EB2C /* ItineraryAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74AB52FEA34FF0050EB2C /* ItineraryAPI.swift */; }; @@ -308,6 +316,12 @@ 55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D73023010000784736 /* PhoneReportDetailVC.swift */; }; 55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D93023010000784738 /* PhoneReportDetailView.swift */; }; 55B217DC302301000078473B /* PhoneReportDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */; }; + 55B218A13024A00100784702 /* AboutVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784701 /* AboutVC.swift */; }; + 55B218A13024A00100784704 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784703 /* AboutView.swift */; }; + 55B218A13024A00100784706 /* CancelAccountVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784705 /* CancelAccountVC.swift */; }; + 55B218A13024A00100784708 /* CancelAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784707 /* CancelAccountView.swift */; }; + 55B218A13024A0010078470A /* MemberInfoVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784709 /* MemberInfoVC.swift */; }; + 55B218A13024A0010078470C /* MemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A0010078470B /* MemberInfoView.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 */; }; @@ -358,6 +372,7 @@ 305A74F62FCA8C7000227D26 /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.swift; sourceTree = ""; }; 305A74F72FCA8C7000227D26 /* UIButton+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+Extension.swift"; sourceTree = ""; }; 305A74F82FCA8C7000227D26 /* UIColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extension.swift"; sourceTree = ""; }; + 55B217A33022F0B100784707 /* UIDevice+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extension.swift"; sourceTree = ""; }; 305A74F92FCA8C7000227D26 /* UIFont+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Extension.swift"; sourceTree = ""; }; 305A74FA2FCA8C7000227D26 /* UIImage+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Extension.swift"; sourceTree = ""; }; 305A74FB2FCA8C7000227D26 /* UIImage+Resource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Resource.swift"; sourceTree = ""; }; @@ -503,6 +518,11 @@ 30A87A632FEE75520095E7C6 /* CreateBubbleTipsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateBubbleTipsView.swift; sourceTree = ""; }; 30A87A652FEE843E0095E7C6 /* CreateBubbleDoneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateBubbleDoneView.swift; sourceTree = ""; }; 30A87A672FEE86560095E7C6 /* CreateBubblePopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateBubblePopView.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 = ""; }; + 55C219C13024C00100784730 /* SearchLocationHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLocationHeaderView.swift; sourceTree = ""; }; 30A87A6A2FEF5B950095E7C6 /* SearchLocationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLocationView.swift; sourceTree = ""; }; 30A87A6C2FEF5BA10095E7C6 /* SearchLocationVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLocationVC.swift; sourceTree = ""; }; 30A87A6E2FEF7BE40095E7C6 /* SearchLocationResultVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLocationResultVC.swift; sourceTree = ""; }; @@ -551,6 +571,8 @@ 30D74AAA2FE8C7700050EB2C /* GPSSignalHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPSSignalHelper.swift; sourceTree = ""; }; 30D74AAD2FEA13E00050EB2C /* ScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleView.swift; sourceTree = ""; }; 30D74AAF2FEA13ED0050EB2C /* ScheduleVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleVC.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 = ""; }; 30D74AB12FEA1D5D0050EB2C /* ScheduleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleViewModel.swift; sourceTree = ""; }; 30D74AB32FEA25B90050EB2C /* ViewedModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewedModel.swift; sourceTree = ""; }; 30D74AB52FEA34FF0050EB2C /* ItineraryAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItineraryAPI.swift; sourceTree = ""; }; @@ -630,6 +652,12 @@ 55B217D73023010000784736 /* PhoneReportDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailVC.swift; sourceTree = ""; }; 55B217D93023010000784738 /* PhoneReportDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailView.swift; sourceTree = ""; }; 55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailViewModel.swift; sourceTree = ""; }; + 55B218A13024A00100784701 /* AboutVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutVC.swift; sourceTree = ""; }; + 55B218A13024A00100784703 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + 55B218A13024A00100784705 /* CancelAccountVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelAccountVC.swift; sourceTree = ""; }; + 55B218A13024A00100784707 /* CancelAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelAccountView.swift; sourceTree = ""; }; + 55B218A13024A00100784709 /* MemberInfoVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberInfoVC.swift; sourceTree = ""; }; + 55B218A13024A0010078470B /* MemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberInfoView.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 = ""; }; @@ -792,6 +820,7 @@ 305A74F62FCA8C7000227D26 /* UIApplicationExtension.swift */, 305A74F72FCA8C7000227D26 /* UIButton+Extension.swift */, 305A74F82FCA8C7000227D26 /* UIColor+Extension.swift */, + 55B217A33022F0B100784707 /* UIDevice+Extension.swift */, 305A74F92FCA8C7000227D26 /* UIFont+Extension.swift */, 305A74FA2FCA8C7000227D26 /* UIImage+Extension.swift */, 305A74FB2FCA8C7000227D26 /* UIImage+Resource.swift */, @@ -1023,6 +1052,7 @@ 30C4C01E2FDC0EA6009215C1 /* GroupInfo */, 307073E42FD18A20004C37CC /* GroupChat */, 30B74B3F2FF2435200F6744D /* GroupMemberList */, + 55B218A13024A00100784712 /* MemberInfo */, 30EFF3A22FD7C58400EB35D4 /* GroupSetting */, 30EFF3B12FD8F19E00EB35D4 /* ReviewMemberList */, 30C4C0172FDBF066009215C1 /* RemoveMember */, @@ -1099,6 +1129,8 @@ 55B217C03022F0A000784720 /* MinePhotoWallView.swift */, 30EFF3CB2FDA667A00EB35D4 /* MyProfile */, 30EFF3BC2FD9585200EB35D4 /* Account */, + 55B218A13024A00100784710 /* About */, + 55B218A13024A00100784711 /* CancelAccount */, 30EFF3D42FDA8EF200EB35D4 /* EmergencyContact */, 30EFF3E32FDAA92200EB35D4 /* PrivacyPolicy */, 30C4C0122FDB9178009215C1 /* CheckPermission */, @@ -1113,6 +1145,7 @@ 305A76352FCA8C7000227D26 /* Map */, 305A76262FCA8C7000227D26 /* Group */, 30D74AAC2FEA13BD0050EB2C /* Schedule */, + 55B218B13024B00100784720 /* Explore */, 305A76392FCA8C7000227D26 /* Mine */, 305A798E2FCAC5F600227D26 /* InviteMember */, 3062E8C52FCFD01000CEF511 /* VipRecharge */, @@ -1380,6 +1413,10 @@ children = ( 30A87A5F2FEE71B80095E7C6 /* CreateBubbleVC.swift */, 30A87A5D2FEE71A50095E7C6 /* CreateBubbleView.swift */, + 55B219C13024C00100784721 /* BubbleHeroView.swift */, + 55B219C13024C00100784723 /* CreateBubbleSetupView.swift */, + 55B219C13024C00100784725 /* BubbleKnowledgeView.swift */, + 55B219C13024C00100784727 /* BubbleKnowledgeVC.swift */, 30A87A612FEE724D0095E7C6 /* CreateBubbleTiemView.swift */, 30A87A632FEE75520095E7C6 /* CreateBubbleTipsView.swift */, 30A87A652FEE843E0095E7C6 /* CreateBubbleDoneView.swift */, @@ -1393,6 +1430,7 @@ children = ( 30A87A6C2FEF5BA10095E7C6 /* SearchLocationVC.swift */, 30A87A6A2FEF5B950095E7C6 /* SearchLocationView.swift */, + 55C219C13024C00100784730 /* SearchLocationHeaderView.swift */, 30A87A6E2FEF7BE40095E7C6 /* SearchLocationResultVC.swift */, 30A87A702FEF7BED0095E7C6 /* SearchLocationResultView.swift */, 30EBF71F2FEFD6F2009A8A87 /* GroupChooseView.swift */, @@ -1526,6 +1564,15 @@ path = SOS; sourceTree = ""; }; + 55B218B13024B00100784720 /* Explore */ = { + isa = PBXGroup; + children = ( + 55B218B13024B00100784721 /* FeatureIntroVC.swift */, + 55B218B13024B00100784723 /* FeatureIntroView.swift */, + ); + path = Explore; + sourceTree = ""; + }; 30D74AAC2FEA13BD0050EB2C /* Schedule */ = { isa = PBXGroup; children = ( @@ -1727,6 +1774,33 @@ path = TodayTrackDetail; sourceTree = ""; }; + 55B218A13024A00100784710 /* About */ = { + isa = PBXGroup; + children = ( + 55B218A13024A00100784701 /* AboutVC.swift */, + 55B218A13024A00100784703 /* AboutView.swift */, + ); + path = About; + sourceTree = ""; + }; + 55B218A13024A00100784711 /* CancelAccount */ = { + isa = PBXGroup; + children = ( + 55B218A13024A00100784705 /* CancelAccountVC.swift */, + 55B218A13024A00100784707 /* CancelAccountView.swift */, + ); + path = CancelAccount; + sourceTree = ""; + }; + 55B218A13024A00100784712 /* MemberInfo */ = { + isa = PBXGroup; + children = ( + 55B218A13024A00100784709 /* MemberInfoVC.swift */, + 55B218A13024A0010078470B /* MemberInfoView.swift */, + ); + path = MemberInfo; + sourceTree = ""; + }; 55B217DE302301000078473D /* PhoneReportDetail */ = { isa = PBXGroup; children = ( @@ -1922,6 +1996,10 @@ 30ACD54E2FF7942E00174861 /* PromotionalActivitiesVC.swift in Sources */, 30EFF3D82FDA8F1000EB35D4 /* EmergencyContactVC.swift in Sources */, 30A87A682FEE86560095E7C6 /* CreateBubblePopView.swift in Sources */, + 55B219C13024C00100784722 /* BubbleHeroView.swift in Sources */, + 55B219C13024C00100784724 /* CreateBubbleSetupView.swift in Sources */, + 55B219C13024C00100784726 /* BubbleKnowledgeView.swift in Sources */, + 55B219C13024C00100784728 /* BubbleKnowledgeVC.swift in Sources */, 30A87A4B2FEE1DAB0095E7C6 /* ItineraryDetailVC.swift in Sources */, 30DC185E2FD1211D0041DCD1 /* VipRightsVC.swift in Sources */, 305A76942FCA8C7000227D26 /* UploadImageCell.swift in Sources */, @@ -1982,6 +2060,7 @@ 305A76AD2FCA8C7000227D26 /* UIApplicationExtension.swift in Sources */, 305A76AE2FCA8C7000227D26 /* UIButton+Extension.swift in Sources */, 305A76AF2FCA8C7000227D26 /* UIColor+Extension.swift in Sources */, + 55B217A33022F0B100784706 /* UIDevice+Extension.swift in Sources */, 305A76B02FCA8C7000227D26 /* UIFont+Extension.swift in Sources */, 55B217A13022F0A000784702 /* FontManager.swift in Sources */, 305A76B12FCA8C7000227D26 /* UIImage+Extension.swift in Sources */, @@ -2023,6 +2102,12 @@ 55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */, 55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */, 55B217DC302301000078473B /* PhoneReportDetailViewModel.swift in Sources */, + 55B218A13024A00100784702 /* AboutVC.swift in Sources */, + 55B218A13024A00100784704 /* AboutView.swift in Sources */, + 55B218A13024A00100784706 /* CancelAccountVC.swift in Sources */, + 55B218A13024A00100784708 /* CancelAccountView.swift in Sources */, + 55B218A13024A0010078470A /* MemberInfoVC.swift in Sources */, + 55B218A13024A0010078470C /* MemberInfoView.swift in Sources */, 30A87A622FEE724D0095E7C6 /* CreateBubbleTiemView.swift in Sources */, 30DC185A2FD11E7A0041DCD1 /* WebOperations.swift in Sources */, 30A87A642FEE75520095E7C6 /* CreateBubbleTipsView.swift in Sources */, @@ -2047,6 +2132,8 @@ 305A76CC2FCA8C7000227D26 /* FileTools.swift in Sources */, 305A76CD2FCA8C7000227D26 /* Permission.swift in Sources */, 30D74AB02FEA13ED0050EB2C /* ScheduleVC.swift in Sources */, + 55B218B13024B00100784722 /* FeatureIntroVC.swift in Sources */, + 55B218B13024B00100784724 /* FeatureIntroView.swift in Sources */, 305A76CE2FCA8C7000227D26 /* RouterManager.swift in Sources */, 3062E8C72FCFD02F00CEF511 /* VipRechargeView.swift in Sources */, 305A76CF2FCA8C7000227D26 /* CountDownService.swift in Sources */, @@ -2079,6 +2166,7 @@ 305A76DF2FCA8C7000227D26 /* Single+ObjectMapper.swift in Sources */, 30DC18602FD12A020041DCD1 /* VipWaivePopView.swift in Sources */, 30D74AAB2FE8C7700050EB2C /* GPSSignalHelper.swift in Sources */, + 55C219C13024C00100784731 /* SearchLocationHeaderView.swift in Sources */, 30A87A6B2FEF5B950095E7C6 /* SearchLocationView.swift in Sources */, 305A76E02FCA8C7000227D26 /* GroupView.swift in Sources */, 55B217B13022F0A000784711 /* GroupItineraryView.swift in Sources */, diff --git a/QuickLocation/Assets.xcassets/Bubble/clock.imageset/Contents.json b/QuickLocation/Assets.xcassets/Bubble/clock.imageset/Contents.json new file mode 100644 index 00000000..4231c99e --- /dev/null +++ b/QuickLocation/Assets.xcassets/Bubble/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/Bubble/clock.imageset/clock@2x.png b/QuickLocation/Assets.xcassets/Bubble/clock.imageset/clock@2x.png new file mode 100644 index 00000000..b2d831c9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/clock.imageset/clock@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/clock.imageset/clock@3x.png b/QuickLocation/Assets.xcassets/Bubble/clock.imageset/clock@3x.png new file mode 100644 index 00000000..0f9bdc11 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/clock.imageset/clock@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/pin.imageset/Contents.json b/QuickLocation/Assets.xcassets/Bubble/pin.imageset/Contents.json new file mode 100644 index 00000000..658022b7 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Bubble/pin.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pin@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pin@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Bubble/pin.imageset/pin@2x.png b/QuickLocation/Assets.xcassets/Bubble/pin.imageset/pin@2x.png new file mode 100644 index 00000000..4d54ee34 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/pin.imageset/pin@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/pin.imageset/pin@3x.png b/QuickLocation/Assets.xcassets/Bubble/pin.imageset/pin@3x.png new file mode 100644 index 00000000..adb4c608 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/pin.imageset/pin@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/about_logo.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/about_logo.imageset/Contents.json new file mode 100644 index 00000000..fd44cdfc --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/about_logo.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "logo@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/about_logo.imageset/logo@2x.png b/QuickLocation/Assets.xcassets/Mine/about_logo.imageset/logo@2x.png new file mode 100644 index 00000000..6d4c1bc0 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/about_logo.imageset/logo@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/about_logo.imageset/logo@3x.png b/QuickLocation/Assets.xcassets/Mine/about_logo.imageset/logo@3x.png new file mode 100644 index 00000000..0506120d Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/about_logo.imageset/logo@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/cancel_check.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/cancel_check.imageset/Contents.json new file mode 100644 index 00000000..6402a56a --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/cancel_check.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "icon@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "icon@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/cancel_check.imageset/icon@2x.png b/QuickLocation/Assets.xcassets/Mine/cancel_check.imageset/icon@2x.png new file mode 100644 index 00000000..df85f4a2 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/cancel_check.imageset/icon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/cancel_check.imageset/icon@3x.png b/QuickLocation/Assets.xcassets/Mine/cancel_check.imageset/icon@3x.png new file mode 100644 index 00000000..db14b5cf Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/cancel_check.imageset/icon@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/cancel_warning.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/cancel_warning.imageset/Contents.json new file mode 100644 index 00000000..6402a56a --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/cancel_warning.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "icon@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "icon@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/cancel_warning.imageset/icon@2x.png b/QuickLocation/Assets.xcassets/Mine/cancel_warning.imageset/icon@2x.png new file mode 100644 index 00000000..82d55a82 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/cancel_warning.imageset/icon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/cancel_warning.imageset/icon@3x.png b/QuickLocation/Assets.xcassets/Mine/cancel_warning.imageset/icon@3x.png new file mode 100644 index 00000000..e80b1349 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/cancel_warning.imageset/icon@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SearchLocation/warning.imageset/Contents.json b/QuickLocation/Assets.xcassets/SearchLocation/warning.imageset/Contents.json new file mode 100644 index 00000000..a0f7a31f --- /dev/null +++ b/QuickLocation/Assets.xcassets/SearchLocation/warning.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "warning@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "warning@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SearchLocation/warning.imageset/warning@2x.png b/QuickLocation/Assets.xcassets/SearchLocation/warning.imageset/warning@2x.png new file mode 100644 index 00000000..149c94d8 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SearchLocation/warning.imageset/warning@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SearchLocation/warning.imageset/warning@3x.png b/QuickLocation/Assets.xcassets/SearchLocation/warning.imageset/warning@3x.png new file mode 100644 index 00000000..97f33c70 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SearchLocation/warning.imageset/warning@3x.png differ diff --git a/QuickLocation/Common/Constant.swift b/QuickLocation/Common/Constant.swift index b93ac3f3..e79de600 100644 --- a/QuickLocation/Common/Constant.swift +++ b/QuickLocation/Common/Constant.swift @@ -29,6 +29,8 @@ extension DefaultsKeys { var unlockCountTotal: DefaultsKey { .init("unlockCountTotal", defaultValue: 0) } /// 解锁统计:日期 yyyy-MM-dd var unlockCountDate: DefaultsKey { .init("unlockCountDate", defaultValue: "") } + /// 解锁统计:按自然日累计次数,key = yyyy-MM-dd + var unlockCountByDay: DefaultsKey<[String: Int]> { .init("unlockCountByDay", defaultValue: [:]) } /// 上次锁屏态(杀进程补计) var unlockWasLocked: DefaultsKey { .init("unlockWasLocked", defaultValue: false) } var unlockLastCountedAt: DefaultsKey { .init("unlockLastCountedAt") } diff --git a/QuickLocation/Core/Extension/UIDevice+Extension.swift b/QuickLocation/Core/Extension/UIDevice+Extension.swift new file mode 100644 index 00000000..058ff1b7 --- /dev/null +++ b/QuickLocation/Core/Extension/UIDevice+Extension.swift @@ -0,0 +1,101 @@ +// +// UIDevice+Extension.swift +// QuickLocation +// + +import UIKit + +extension UIDevice { + + /// 硬件机型标识,如 `iPhone17,1` + static var machineIdentifier: String { + var systemInfo = utsname() + uname(&systemInfo) + return withUnsafePointer(to: &systemInfo.machine.0) { ptr in + String(cString: ptr) + } + } + + /// 可读机型名,如 `iPhone 16 Pro`;未知机型回退为 `iPhone` + static var modelName: String { + deviceModelMap[machineIdentifier] ?? "iPhone" + } + + /// 与系统状态栏一致的电量百分比(四舍五入);不可用时返回 `nil` + static var batteryPercent: Int? { + let device = UIDevice.current + if !device.isBatteryMonitoringEnabled { + device.isBatteryMonitoringEnabled = true + } + let level = device.batteryLevel + guard level >= 0 else { return nil } + return Int((level * 100).rounded()) + } + + /// 是否正在充电 / 已充满接电 + static var isBatteryCharging: Bool { + let device = UIDevice.current + if !device.isBatteryMonitoringEnabled { + device.isBatteryMonitoringEnabled = true + } + switch device.batteryState { + case .charging, .full: + return true + default: + return false + } + } + + private static let deviceModelMap: [String: String] = [ + "iPhone10,1": "iPhone 8", + "iPhone10,4": "iPhone 8", + "iPhone10,2": "iPhone 8 Plus", + "iPhone10,5": "iPhone 8 Plus", + // iPhone X系列 + "iPhone10,3": "iPhone X", + "iPhone10,6": "iPhone X", + "iPhone11,2": "iPhone XS", + "iPhone11,4": "iPhone XS Max", + "iPhone11,6": "iPhone XS Max", + "iPhone11,8": "iPhone XR", + // iPhone11 + "iPhone12,1": "iPhone 11", + "iPhone12,3": "iPhone 11 Pro", + "iPhone12,5": "iPhone 11 Pro Max", + // iPhone12 + "iPhone13,1": "iPhone 12 mini", + "iPhone13,2": "iPhone 12", + "iPhone13,3": "iPhone 12 Pro", + "iPhone13,4": "iPhone 12 Pro Max", + // iPhone13 + "iPhone14,4": "iPhone 13 mini", + "iPhone14,5": "iPhone 13", + "iPhone14,2": "iPhone 13 Pro", + "iPhone14,3": "iPhone 13 Pro Max", + // iPhone14 + "iPhone14,7": "iPhone 14", + "iPhone14,8": "iPhone 14 Plus", + "iPhone15,2": "iPhone 14 Pro", + "iPhone15,3": "iPhone 14 Pro Max", + // iPhone15 + "iPhone15,4": "iPhone 15", + "iPhone15,5": "iPhone 15 Plus", + "iPhone16,1": "iPhone 15 Pro", + "iPhone16,2": "iPhone 15 Pro Max", + // iPhone16 + "iPhone17,1": "iPhone 16 Pro", + "iPhone17,2": "iPhone 16 Pro Max", + "iPhone17,3": "iPhone 16", + "iPhone17,4": "iPhone 16 Plus", + "iPhone17,5": "iPhone 16e", + // iPhone17 2025机型 + "iPhone18,1": "iPhone 17 Pro", + "iPhone18,2": "iPhone 17 Pro Max", + "iPhone18,3": "iPhone 17", + "iPhone18,4": "iPhone Air", + // Simulator + "i386": "Simulator", + "x86_64": "Simulator", + "arm64": "Simulator" + ] +} diff --git a/QuickLocation/Main/Tabbar/MainTabBarController.swift b/QuickLocation/Main/Tabbar/MainTabBarController.swift index 07940d85..878b60f5 100644 --- a/QuickLocation/Main/Tabbar/MainTabBarController.swift +++ b/QuickLocation/Main/Tabbar/MainTabBarController.swift @@ -9,7 +9,7 @@ final class MainTabBarController: UITabBarController { // MARK: - View Controllers private lazy var locationNav = BaseNavigationController(rootViewController: HomeViewController()) - private lazy var exploreNav = BaseNavigationController(rootViewController: ScheduleVC()) + private lazy var exploreNav = BaseNavigationController(rootViewController: FeatureIntroVC()) private lazy var circlesNav = BaseNavigationController(rootViewController: GroupViewController()) private lazy var mineNav = BaseNavigationController(rootViewController: MineViewController()) @@ -93,6 +93,14 @@ final class MainTabBarController: UITabBarController { func setTabBarHidden(_ hidden: Bool) { customTabBar.isHidden = hidden } + + /// 切换自定义 Tab(需同步 customTabBar 选中态与 UITabBarController.selectedIndex) + func selectTab(at index: Int) { + guard index >= 0, index < navControllers.count else { return } + customTabBar.setSelectedIndex(index) + selectedIndex = index + updateTabBarVisibility() + } } // MARK: - QuickLocationTabBarDelegate diff --git a/QuickLocation/Manager/App/ApiManager.swift b/QuickLocation/Manager/App/ApiManager.swift index 6278af95..891f2a22 100644 --- a/QuickLocation/Manager/App/ApiManager.swift +++ b/QuickLocation/Manager/App/ApiManager.swift @@ -10,6 +10,7 @@ import Moya import SwiftyJSON import RxSwift import CommonCrypto +import UIKit #if !targetEnvironment(simulator) import GeYanSdk #endif @@ -56,12 +57,7 @@ extension ApiManager { headers["x-version"] = kAppShortVersion headers["x-device-id"] = ApiManager.getMD5ID() // 机型 - var systemInfo = utsname() - uname(&systemInfo) - let platform = withUnsafePointer(to: &systemInfo.machine.0) { ptr in - return String(cString: ptr) - } - headers["x-mobile-model"] = platform + headers["x-mobile-model"] = UIDevice.machineIdentifier if let loginToken = Defaults[\.loginToken] { headers["x-token"] = loginToken diff --git a/QuickLocation/Manager/App/RouterManager.swift b/QuickLocation/Manager/App/RouterManager.swift index 3bdbc83f..a2d6f3ea 100644 --- a/QuickLocation/Manager/App/RouterManager.swift +++ b/QuickLocation/Manager/App/RouterManager.swift @@ -79,6 +79,16 @@ enum Route: String { case todayTrackDetail = "todayTrackDetail" /// 手机报告详情 case phoneReportDetail = "phoneReportDetail" + /// 关于我们 + case about = "about" + /// 成员信息 + case memberInfo = "memberInfo" + /// 注销账号 + case cancelAccount = "cancelAccount" + /// 还在吗 / 打卡 + case signIn = "signIn" + /// SOS + case sos = "sos" /// 充值优惠弹窗 case promotionalActivities = "promotionalActivities" } @@ -362,6 +372,31 @@ extension AppRouter: AppRouterProtocol { AppRouter.register(Route.phoneReportDetail) { url, parameters in PhoneReportDetailVC(userInfo: parameters) } + + // MARK: - 关于我们 + AppRouter.register(Route.about) { _, _ in + AboutVC() + } + + // MARK: - 成员信息 + AppRouter.register(Route.memberInfo) { _, parameters in + MemberInfoVC(userInfo: parameters) + } + + // MARK: - 注销账号 + AppRouter.register(Route.cancelAccount) { _, _ in + CancelAccountVC() + } + + // MARK: - 还在吗 + AppRouter.register(Route.signIn) { _, _ in + SignInVC(lastLocation: nil) + } + + // MARK: - SOS + AppRouter.register(Route.sos) { _, _ in + SOSViewController() + } // MARK: - 充值优惠弹窗 AppRouter.register(Route.promotionalActivities) { url, parameters in diff --git a/QuickLocation/Manager/Unlock/UnlockCountManager.swift b/QuickLocation/Manager/Unlock/UnlockCountManager.swift index c6edee4c..3055f5df 100644 --- a/QuickLocation/Manager/Unlock/UnlockCountManager.swift +++ b/QuickLocation/Manager/Unlock/UnlockCountManager.swift @@ -187,10 +187,18 @@ final class UnlockCountManager: NSObject { private func rollDayIfNeeded() -> Bool { migrateScreenTimeStorageIfNeeded() let today = todayString() - guard Defaults[\.unlockCountDate] != today else { return false } + let previousDate = Defaults[\.unlockCountDate] + guard previousDate != today else { return false } flushScreenSession() + // 切日前落盘旧日解锁次数 + if !previousDate.isEmpty { + var byDay = Defaults[\.unlockCountByDay] + byDay[previousDate] = Defaults[\.unlockCountToday] + Defaults[\.unlockCountByDay] = byDay + } + Defaults[\.unlockCountDate] = today Defaults[\.unlockCountToday] = 0 Defaults[\.appUsageToday] = 0 @@ -308,6 +316,9 @@ final class UnlockCountManager: NSObject { rollDayIfNeeded() Defaults[\.unlockCountToday] = Defaults[\.unlockCountToday] + 1 Defaults[\.unlockCountTotal] = Defaults[\.unlockCountTotal] + 1 + var byDay = Defaults[\.unlockCountByDay] + byDay[todayString()] = Defaults[\.unlockCountToday] + Defaults[\.unlockCountByDay] = byDay Defaults[\.unlockLastCountedAt] = now Defaults[\.unlockWasLocked] = false isSessionLocked = false diff --git a/QuickLocation/Section/Explore/FeatureIntroVC.swift b/QuickLocation/Section/Explore/FeatureIntroVC.swift new file mode 100644 index 00000000..e162e255 --- /dev/null +++ b/QuickLocation/Section/Explore/FeatureIntroVC.swift @@ -0,0 +1,52 @@ +// +// FeatureIntroVC.swift +// QuickLocation +// + +import UIKit +import CoreLocation +import SwiftyUserDefaults + +final class FeatureIntroVC: BaseViewController { + + override var isNavigationBarHidden: Bool { true } + + private var rootView: FeatureIntroView! + + override func loadView() { + rootView = FeatureIntroView(frame: UIScreen.main.bounds) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + rootView.onTapItem = { [weak self] action in + self?.handle(action) + } + } + + private func handle(_ action: FeatureIntroAction) { + switch action { + case .signIn: + let vc = SignInVC(lastLocation: savedLastLocation) + vc.isNeedLogin = true + AppRouter.push(vc) + case .createBubble: + AppRouter.push(Route.createBubble) + case .searchLocation: + AppRouter.push(Route.searchLocation) + case .sos: + let vc = SOSViewController() + vc.isNeedLogin = true + AppRouter.push(vc) + case .placeholder: + DLToast.show(text: "敬请期待") + } + } + + private var savedLastLocation: CLLocation? { + guard let lat = Defaults[\.currentLatitude], + let lon = Defaults[\.currentLongitude] else { return nil } + return CLLocation(latitude: lat, longitude: lon) + } +} diff --git a/QuickLocation/Section/Explore/FeatureIntroView.swift b/QuickLocation/Section/Explore/FeatureIntroView.swift new file mode 100644 index 00000000..8f1d75d5 --- /dev/null +++ b/QuickLocation/Section/Explore/FeatureIntroView.swift @@ -0,0 +1,193 @@ +// +// FeatureIntroView.swift +// QuickLocation +// + +import UIKit + +struct FeatureIntroItem { + let title: String + let subtitle: String + let background: UIColor + let titleColor: UIColor + let subtitleColor: UIColor + let arrowTint: UIColor + let action: FeatureIntroAction +} + +enum FeatureIntroAction { + case signIn + case createBubble + case searchLocation + case sos + case placeholder +} + +final class FeatureIntroView: UIView { + + var onTapItem: ((FeatureIntroAction) -> Void)? + + private let headerTitleLab = UILabel() + private let headerSubLab = UILabel() + private let sheet = UIView() + private let grid = UIStackView() + + private let items: [FeatureIntroItem] = [ + FeatureIntroItem( + title: "还在吗?", + subtitle: "每日打卡签到 记录美好生活", + background: UIColor(hexStr: "#EDE7FF"), + titleColor: UIColor(hexStr: "#5B4B8A"), + subtitleColor: UIColor(hexStr: "#8B7BB8"), + arrowTint: UIColor(hexStr: "#7B6AAE"), + action: .signIn + ), + FeatureIntroItem( + title: "隐身气泡", + subtitle: "只想做个淡人 默默隐身", + background: UIColor(hexStr: "#D9ECFF"), + titleColor: UIColor(hexStr: "#2F6FAE"), + subtitleColor: UIColor(hexStr: "#6A9BC4"), + arrowTint: UIColor(hexStr: "#3E8FD0"), + action: .createBubble + ), + FeatureIntroItem( + title: "一键锁机", + subtitle: "专注当下 拒绝手机干扰", + background: UIColor(hexStr: "#DDF5E8"), + titleColor: UIColor(hexStr: "#2F8A5B"), + subtitleColor: UIColor(hexStr: "#6AAD88"), + arrowTint: UIColor(hexStr: "#3EAE72"), + action: .placeholder + ), + FeatureIntroItem( + title: "飞鸽传书", + subtitle: "实时查看位置 守护安全出行", + background: UIColor(hexStr: "#FFF3D6"), + titleColor: UIColor(hexStr: "#A67C2D"), + subtitleColor: UIColor(hexStr: "#C4A46A"), + arrowTint: UIColor(hexStr: "#C9953A"), + action: .placeholder + ), + FeatureIntroItem( + title: "查位置", + subtitle: "实时查看位置 守护安全出行", + background: UIColor(hexStr: "#D6F4F2"), + titleColor: UIColor(hexStr: "#2A8A84"), + subtitleColor: UIColor(hexStr: "#66B0AB"), + arrowTint: UIColor(hexStr: "#35A8A0"), + action: .searchLocation + ), + FeatureIntroItem( + title: "SOS", + subtitle: "紧急求助 一键呼叫", + background: UIColor(hexStr: "#FFE4E8"), + titleColor: UIColor(hexStr: "#D6455D"), + subtitleColor: UIColor(hexStr: "#E28A98"), + arrowTint: UIColor(hexStr: "#E2556C"), + action: .sos + ) + ] + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#F0F1F3") + setupUI() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func setupUI() { + headerTitleLab.text = "功能介绍" + headerTitleLab.font = .systemFont(ofSize: 28, weight: .bold) + headerTitleLab.textColor = UIColor(hexStr: "#1F2A44") + addSubview(headerTitleLab) + headerTitleLab.layoutChain.top(kStatusBarHeight + 20).left(20) + + headerSubLab.text = "视频+图片" + headerSubLab.font = .systemFont(ofSize: 28, weight: .bold) + headerSubLab.textColor = UIColor(hexStr: "#1F2A44") + addSubview(headerSubLab) + headerSubLab.layoutChain.topToBottomOfView(headerTitleLab, offset: 2).left(20) + + sheet.backgroundColor = .white + sheet.layer.cornerRadius = 28 + sheet.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + sheet.clipsToBounds = true + addSubview(sheet) + sheet.layoutChain + .topToBottomOfView(headerSubLab, offset: 24) + .edgesHorzontal() + .bottom() + + grid.axis = .vertical + grid.spacing = 12 + grid.distribution = .fillEqually + sheet.addSubview(grid) + grid.layoutChain + .top(24) + .edgesHorzontal(16) + .bottom(24 + 70) // leave room for custom tab bar + + for row in 0..<3 { + let rowStack = UIStackView() + rowStack.axis = .horizontal + rowStack.spacing = 12 + rowStack.distribution = .fillEqually + for col in 0..<2 { + let item = items[row * 2 + col] + let card = FeatureIntroCardView(item: item) + card.addTarget(self, action: #selector(cardTapped(_:)), for: .touchUpInside) + card.tag = row * 2 + col + rowStack.addArrangedSubview(card) + } + grid.addArrangedSubview(rowStack) + } + } + + @objc private func cardTapped(_ sender: FeatureIntroCardView) { + let idx = sender.tag + guard items.indices.contains(idx) else { return } + onTapItem?(items[idx].action) + } +} + +final class FeatureIntroCardView: UIControl { + private let titleLab = UILabel() + private let subLab = UILabel() + private let arrowBtn = UIView() + + init(item: FeatureIntroItem) { + super.init(frame: .zero) + backgroundColor = item.background + layer.cornerRadius = 20 + clipsToBounds = true + + titleLab.text = item.title + titleLab.font = .systemFont(ofSize: 18, weight: .bold) + titleLab.textColor = item.titleColor + addSubview(titleLab) + titleLab.layoutChain.top(16).left(14).right(14) + + subLab.text = item.subtitle + subLab.font = .systemFont(ofSize: 11, weight: .medium) + subLab.textColor = item.subtitleColor + subLab.numberOfLines = 2 + addSubview(subLab) + subLab.layoutChain.topToBottomOfView(titleLab, offset: 6).left(14).right(14) + + arrowBtn.backgroundColor = .white + arrowBtn.isUserInteractionEnabled = false + arrowBtn.layer.cornerRadius = 14 + addSubview(arrowBtn) + arrowBtn.layoutChain.left(14).bottom(14).width(28).height(28) + + let arrow = UIImageView(image: UIImage(systemName: "chevron.right")) + arrow.tintColor = item.arrowTint + arrow.contentMode = .scaleAspectFit + arrowBtn.addSubview(arrow) + arrow.layoutChain.center().width(10).height(12) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} diff --git a/QuickLocation/Section/Group/InviteJoin/InviteJoinVC.swift b/QuickLocation/Section/Group/InviteJoin/InviteJoinVC.swift index e7be01c7..565485c4 100644 --- a/QuickLocation/Section/Group/InviteJoin/InviteJoinVC.swift +++ b/QuickLocation/Section/Group/InviteJoin/InviteJoinVC.swift @@ -27,8 +27,9 @@ class InviteJoinVC: BaseViewController { super.viewDidLoad() // 分享邀请卡片到微信 - rootView.shareBtn.rx.tap.subscribe(onNext: { [weak self] in - self?.shareInviteCard() + rootView.shareBtn.rx.tap.subscribe(onNext: { _ in + SharePopView.show() +// self?.shareInviteCard() }).disposed(by: disposeBag) // 下载图片到相册 diff --git a/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift b/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift new file mode 100644 index 00000000..73198a88 --- /dev/null +++ b/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift @@ -0,0 +1,159 @@ +// +// MemberInfoVC.swift +// QuickLocation +// + +import UIKit +import RxSwift +import RxCocoa +import ObjectMapper +import Kingfisher + +final class MemberInfoVC: BaseViewController { + + private var rootView: MemberInfoView! + private var members: [GroupMemberModel] + private var selectedUserId: String + private var groupModel: GroupModel? + + init(members: [GroupMemberModel], selectedUserId: String, groupModel: GroupModel?) { + self.members = members + self.selectedUserId = selectedUserId + self.groupModel = groupModel + super.init(nibName: nil, bundle: nil) + } + + convenience init(userInfo: [String: Any]) { + let rawMembers = userInfo["members"] + let membersJson: [[String: Any]] + if let arr = rawMembers as? [[String: Any]] { + membersJson = arr + } else if let arr = rawMembers as? [Any] { + membersJson = arr.compactMap { $0 as? [String: Any] } + } else { + membersJson = [] + } + let members = membersJson.compactMap { GroupMemberModel(JSON: $0) } + let selected = (userInfo["userId"] as? String) ?? members.first?.user_id ?? "" + let group = (userInfo["groupJson"] as? [String: Any]).flatMap { GroupModel(JSON: $0) } + self.init(members: members, selectedUserId: selected, groupModel: group) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func loadView() { + rootView = MemberInfoView(frame: UIScreen.main.bounds) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + rootView.memberCV.dataSource = self + rootView.memberCV.delegate = self + bind() + refreshDetail() + rootView.memberCV.reloadData() + } + + private func bind() { + rootView.navView.onBackTapped = { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + rootView.switchGroupBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.switchGroup() + }) + .disposed(by: disposeBag) + } + + private func switchGroup() { + guard let groupModel else { return } + GroupListPopView.show(groupModel: groupModel) { [weak self] groupKey in + guard let self, let key = groupKey else { return } + GroupService.operate(opType: "setdefault", requestData: ["group_key": key]) + .subscribe(onNext: { [weak self] _ in + NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil) + self?.navigationController?.popViewController(animated: true) + }) + .disposed(by: self.disposeBag) + } + } + + private func refreshDetail() { + let member = members.first { $0.user_id == selectedUserId } + let isSelf = selectedUserId == AppContextManager.shared.userId + let joinText: String = { + guard let ts = member?.join_time, ts > 0 else { return "**" } + let date = Date(timeIntervalSince1970: TimeInterval(ts) / 1000) + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f.string(from: date) + }() + let circleName: String = { + guard let gm = groupModel else { return "共同圈子" } + let name = gm.groups.first(where: { $0.group_key == gm.default_group_key })?.name + ?? gm.groups.first?.name + return (name?.isEmpty == false) ? name! : "共同圈子" + }() + + if isSelf { + let sexText: String = { + switch AppContextManager.shared.sex { + case 1: return "男" + case 2: return "女" + default: return "未设置" + } + }() + rootView.renderRows([ + MemberInfoRow(title: "头像", value: "", kind: .avatar, avatarURL: member?.head_pic) { [weak self] in + AppRouter.push(Route.myProfile) + _ = self + }, + MemberInfoRow(title: "昵称", value: member?.nick_name ?? "", kind: .disclosure) { + AppRouter.push(Route.myProfile) + }, + MemberInfoRow(title: "性别", value: sexText, kind: .disclosure) { + AppRouter.push(Route.myProfile) + }, + MemberInfoRow(title: "添加时间", value: joinText, kind: .text), + MemberInfoRow(title: "共同圈子", value: circleName, kind: .tag) + ]) + } else { + rootView.renderRows([ + MemberInfoRow(title: "昵称", value: member?.nick_name ?? "", kind: .text), + MemberInfoRow(title: "备注", value: member?.nick_name ?? "", kind: .disclosure) { + DLToast.show(text: "敬请期待") + }, + MemberInfoRow(title: "关系", value: "未绑定", kind: .disclosure) { + DLToast.show(text: "敬请期待") + }, + MemberInfoRow(title: "性别", value: "保密", kind: .text), + MemberInfoRow(title: "添加时间", value: joinText, kind: .text), + MemberInfoRow(title: "共同圈子", value: circleName, kind: .tag) + ]) + } + } +} + +extension MemberInfoVC: UICollectionViewDataSource, UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + members.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath) + let model = members[indexPath.item] + cell.configure( + model: model, + isCurrentUser: model.user_id == AppContextManager.shared.userId, + isSelected: model.user_id == selectedUserId + ) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + selectedUserId = members[indexPath.item].user_id + collectionView.reloadData() + refreshDetail() + } +} diff --git a/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift b/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift new file mode 100644 index 00000000..78c7ef37 --- /dev/null +++ b/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift @@ -0,0 +1,196 @@ +// +// MemberInfoView.swift +// QuickLocation +// + +import UIKit +import Kingfisher + +final class MemberInfoView: UIView { + + lazy var navView = BaseNavigationView(title: "成员信息") + + let switchGroupBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.backgroundColor = .white.withAlphaComponent(0.8) + btn.setTitle(" 切换圈子 ", for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) + btn.setTitleColor(UIColor(hexStr: "#293445"), for: .normal) + btn.cornerRadius = 8 + return btn + }() + + lazy var memberCV: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.itemSize = CGSize(width: 61, height: 90) + layout.minimumLineSpacing = 12 + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.backgroundColor = .white + cv.showsHorizontalScrollIndicator = false + cv.contentInset = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + cv.register(GroupMemberListCell.self) + cv.layer.cornerRadius = 20 + return cv + }() + + let scrollView = UIScrollView() + let contentView = UIView() + let detailCard = UIView() + private let rowsStack = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#F7F7F7") + setupUI() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func setupUI() { + addSubview(navView) + navView.layoutChain.top().edgesHorzontal().height(kNaviHeight) + navView.addRightButton(switchGroupBtn) + + addSubview(scrollView) + scrollView.layoutChain.topToBottomOfView(navView).edgesHorzontal().bottom() + scrollView.addSubview(contentView) + contentView.layoutChain.edges().widthToView(scrollView) + + contentView.addSubview(memberCV) + memberCV.layoutChain.top(8).edgesHorzontal(15).height(100) + + detailCard.backgroundColor = .white + detailCard.layer.cornerRadius = 16 + contentView.addSubview(detailCard) + detailCard.layoutChain + .topToBottomOfView(memberCV, offset: 12) + .edgesHorzontal(15) + .bottom(24) + + rowsStack.axis = .vertical + rowsStack.spacing = 0 + detailCard.addSubview(rowsStack) + rowsStack.layoutChain.edges(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) + } + + func renderRows(_ rows: [MemberInfoRow]) { + rowsStack.arrangedSubviews.forEach { + rowsStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + for (i, row) in rows.enumerated() { + let view = MemberInfoRowView(row: row) + rowsStack.addArrangedSubview(view) + view.layoutChain.height(row.kind == .tag ? 56 : 52) + if i < rows.count - 1 { + let line = UIView() + line.backgroundColor = UIColor(hexStr: "#F2F2F2") + view.addSubview(line) + line.layoutChain.left(15).right(15).bottom(0).height(0.5) + } + } + } +} + +enum MemberInfoRowKind { + case text + case avatar + case tag + case disclosure +} + +struct MemberInfoRow { + let title: String + let value: String + let kind: MemberInfoRowKind + let action: (() -> Void)? + let avatarURL: String? + + init(title: String, value: String, kind: MemberInfoRowKind = .text, avatarURL: String? = nil, action: (() -> Void)? = nil) { + self.title = title + self.value = value + self.kind = kind + self.avatarURL = avatarURL + self.action = action + } +} + +final class MemberInfoRowView: UIControl { + private let titleLab = UILabel() + private let valueLab = UILabel() + private let arrow = UIImageView(image: UIImage(named: "Mine/arrow_right")) + private let avatar = UIImageView() + private let tagLab = UILabel() + private let actionHandler: (() -> Void)? + + init(row: MemberInfoRow) { + self.actionHandler = row.action + super.init(frame: .zero) + titleLab.text = row.title + titleLab.font = .systemFont(ofSize: 14, weight: .medium) + titleLab.textColor = UIColor(hexStr: "#293445") + addSubview(titleLab) + titleLab.layoutChain.left(15).centerY() + + valueLab.text = row.value + valueLab.font = .systemFont(ofSize: 14, weight: .medium) + valueLab.textColor = row.value == "未设置" || row.value == "未绑定" || row.value == "保密" + ? UIColor(hexStr: "#AAAAAA") + : UIColor(hexStr: "#293445") + valueLab.textAlignment = .right + + arrow.contentMode = .scaleAspectFit + avatar.contentMode = .scaleAspectFill + avatar.clipsToBounds = true + avatar.layer.cornerRadius = 14 + + tagLab.text = row.value + tagLab.font = .systemFont(ofSize: 12, weight: .medium) + tagLab.textColor = UIColor(hexStr: "#E8A045") + tagLab.backgroundColor = UIColor(hexStr: "#FFF3E0") + tagLab.textAlignment = .center + tagLab.layer.cornerRadius = 10 + tagLab.clipsToBounds = true + + switch row.kind { + case .text: + addSubview(valueLab) + valueLab.layoutChain.right(15).centerY().leftToRightOfView(titleLab, offset: 12) + case .disclosure: + addSubview(arrow) + arrow.layoutChain.right(15).centerY().width(12).height(12) + addSubview(valueLab) + valueLab.layoutChain.rightToLeftOfView(arrow, offset: 6).centerY().leftToRightOfView(titleLab, offset: 12) + case .avatar: + addSubview(arrow) + arrow.layoutChain.right(15).centerY().width(12).height(12) + addSubview(avatar) + avatar.layoutChain.rightToLeftOfView(arrow, offset: 8).centerY().width(28).height(28) + if let pic = row.avatarURL, !pic.isEmpty, let img = UIImage(named: "UserIcon/\(pic)") { + avatar.image = img + } else if let urlStr = row.avatarURL, let u = URL(string: urlStr), urlStr.hasPrefix("http") { + avatar.kf.setImage(with: u, placeholder: UIImage(named: "Common/default_avatar")) + } else { + avatar.image = UIImage(named: "Common/default_avatar") + } + case .tag: + addSubview(tagLab) + tagLab.layoutChain.right(15).centerY().height(20) + tagLab.setContentHuggingPriority(.required, for: .horizontal) + let pad = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) + tagLab.layoutMargins = pad + // approximate width from text + let w = (row.value as NSString).size(withAttributes: [.font: tagLab.font!]).width + 20 + tagLab.layoutChain.width(max(60, w)) + } + + if row.action != nil { + addTarget(self, action: #selector(tap), for: .touchUpInside) + } + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + @objc private func tap() { actionHandler?() } +} diff --git a/QuickLocation/Section/Home/Bubble/BubbleHeroView.swift b/QuickLocation/Section/Home/Bubble/BubbleHeroView.swift new file mode 100644 index 00000000..8d236d2f --- /dev/null +++ b/QuickLocation/Section/Home/Bubble/BubbleHeroView.swift @@ -0,0 +1,230 @@ +// +// BubbleHeroView.swift +// QuickLocation +// + +import UIKit + +/// 绘制气泡 + 当前用户头像在圆内持续碰撞回弹 +final class BubbleHeroView: UIView { + + private let bubbleContainer = UIView() + private let gradientLayer = CAGradientLayer() + private let bubbleMaskLayer = CAShapeLayer() + private let borderLayer = CAShapeLayer() + private let avatarView = UIImageView() + private let decoBubble1 = UIView() + private let decoBubble2 = UIView() + + private var displayLink: CADisplayLink? + private var avatarCenter = CGPoint.zero + private var velocity = CGPoint(x: 42, y: 36) + private var avatarAngle: CGFloat = 0 + private var lastTimestamp: CFTimeInterval = 0 + private var sameDirectionBounceCount = 0 + private var lastBounceNormal = CGPoint.zero + private var hasRandomizedMotion = false + + private let avatarSize: CGFloat = 64 + private let moveSpeed: CGFloat = 85 + + override init(frame: CGRect) { + super.init(frame: frame) + clipsToBounds = false + setupUI() + reloadAvatar() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + deinit { stopAnimation() } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + if UIAccessibility.isReduceMotionEnabled { + stopAnimation() + resetAvatarCenter(randomize: false) + } else { + startAnimation() + } + } else { + stopAnimation() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + let diameter = bounds.width + guard diameter > 0 else { return } + + let bubbleFrame = CGRect(x: 0, y: 0, width: diameter, height: diameter) + bubbleContainer.frame = bubbleFrame + gradientLayer.frame = bubbleContainer.bounds + bubbleMaskLayer.path = UIBezierPath(ovalIn: bubbleContainer.bounds).cgPath + bubbleMaskLayer.frame = bubbleContainer.bounds + borderLayer.path = UIBezierPath(ovalIn: bubbleContainer.bounds).cgPath + borderLayer.frame = bubbleContainer.bounds + + decoBubble1.frame = CGRect(x: bubbleFrame.minX + diameter * 0.12, y: bubbleFrame.maxY + 8, width: 32, height: 32) + decoBubble2.frame = CGRect(x: bubbleFrame.maxX - diameter * 0.22, y: bubbleFrame.maxY + 18, width: 20, height: 20) + decoBubble1.layer.cornerRadius = 16 + decoBubble2.layer.cornerRadius = 10 + + if avatarCenter == .zero || !bubbleContainer.bounds.contains(avatarCenter) { + resetAvatarCenter(randomize: true) + } + layoutAvatar() + } + + func reloadAvatar() { + let icon = AppContextManager.shared.avaterIcon + avatarView.image = icon.size.width > 0 ? icon : UIImage(named: "Common/default_avatar") + } + + private func setupUI() { + bubbleContainer.clipsToBounds = true + addSubview(bubbleContainer) + + gradientLayer.type = .radial + gradientLayer.colors = [ + UIColor.white.cgColor, + UIColor.white.cgColor, + UIColor(hexStr: "#FFE8F2").cgColor, + UIColor(hexStr: "#FFC9EF").cgColor + ] + gradientLayer.locations = [0, 0.58, 0.82, 1] + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.46) + gradientLayer.endPoint = CGPoint(x: 1, y: 1) + bubbleContainer.layer.addSublayer(gradientLayer) + + bubbleMaskLayer.fillColor = UIColor.white.cgColor + bubbleContainer.layer.mask = bubbleMaskLayer + + borderLayer.fillColor = UIColor.clear.cgColor + borderLayer.strokeColor = UIColor.white.withAlphaComponent(0.3).cgColor + borderLayer.lineWidth = 1 + bubbleContainer.layer.addSublayer(borderLayer) + + avatarView.contentMode = .scaleAspectFill + avatarView.clipsToBounds = true + avatarView.layer.cornerRadius = 16 + avatarView.layer.borderWidth = 3 + avatarView.layer.borderColor = UIColor.white.cgColor + bubbleContainer.addSubview(avatarView) + + decoBubble1.backgroundColor = UIColor(hexStr: "#FFD6E8").withAlphaComponent(0.85) + decoBubble2.backgroundColor = UIColor(hexStr: "#FFD6E8").withAlphaComponent(0.65) + addSubview(decoBubble1) + addSubview(decoBubble2) + } + + private func randomizeVelocity() { + let angle = CGFloat.random(in: 0...(2 * .pi)) + velocity = CGPoint(x: cos(angle) * moveSpeed, y: sin(angle) * moveSpeed) + hasRandomizedMotion = true + } + + private func normalizeVelocity() { + let speed = hypot(velocity.x, velocity.y) + guard speed > 0.001 else { + randomizeVelocity() + return + } + let scale = moveSpeed / speed + velocity.x *= scale + velocity.y *= scale + } + + private func resetAvatarCenter(randomize: Bool) { + let bounds = bubbleContainer.bounds + guard bounds.width > 0 else { return } + + let offsetRange = bounds.width * 0.08 + avatarCenter = CGPoint( + x: bounds.midX + CGFloat.random(in: -offsetRange...offsetRange), + y: bounds.midY + CGFloat.random(in: -offsetRange...offsetRange) + ) + if randomize { + avatarAngle = CGFloat.random(in: -0.21...0.21) + randomizeVelocity() + sameDirectionBounceCount = 0 + lastBounceNormal = .zero + } + layoutAvatar() + } + + private func layoutAvatar() { + avatarView.bounds = CGRect(x: 0, y: 0, width: avatarSize, height: avatarSize) + avatarView.center = avatarCenter + avatarView.transform = CGAffineTransform(rotationAngle: avatarAngle) + } + + private func startAnimation() { + guard displayLink == nil else { return } + if !hasRandomizedMotion { + randomizeVelocity() + } + lastTimestamp = 0 + let link = CADisplayLink(target: self, selector: #selector(tick(_:))) + link.add(to: .main, forMode: .common) + displayLink = link + } + + private func stopAnimation() { + displayLink?.invalidate() + displayLink = nil + lastTimestamp = 0 + } + + @objc private func tick(_ link: CADisplayLink) { + guard bubbleContainer.bounds.width > 0 else { return } + let dt = lastTimestamp > 0 ? CGFloat(link.timestamp - lastTimestamp) : CGFloat(1.0 / 60.0) + lastTimestamp = link.timestamp + guard dt > 0, dt < 0.05 else { return } + + let bubbleRadius = min(bubbleContainer.bounds.width, bubbleContainer.bounds.height) / 2 + let maxDist = max(8, bubbleRadius - avatarSize / 2 - 4) + let center = CGPoint(x: bubbleContainer.bounds.midX, y: bubbleContainer.bounds.midY) + + avatarCenter.x += velocity.x * dt + avatarCenter.y += velocity.y * dt + + let offset = CGPoint(x: avatarCenter.x - center.x, y: avatarCenter.y - center.y) + let dist = hypot(offset.x, offset.y) + if dist > maxDist { + let nx = offset.x / max(dist, 0.001) + let ny = offset.y / max(dist, 0.001) + avatarCenter.x = center.x + nx * maxDist + avatarCenter.y = center.y + ny * maxDist + + if abs(nx - lastBounceNormal.x) < 0.15 && abs(ny - lastBounceNormal.y) < 0.15 { + sameDirectionBounceCount += 1 + } else { + sameDirectionBounceCount = 0 + } + lastBounceNormal = CGPoint(x: nx, y: ny) + + let dot = velocity.x * nx + velocity.y * ny + velocity.x = velocity.x - 2 * dot * nx + velocity.y = velocity.y - 2 * dot * ny + + let tx = -ny + let ty = nx + let jitterSign: CGFloat = Bool.random() ? 1 : -1 + let jitter = CGFloat.random(in: 16...26) * jitterSign + velocity.x += tx * jitter + velocity.y += ty * jitter + normalizeVelocity() + avatarAngle += CGFloat.random(in: -0.05...0.05) + + if sameDirectionBounceCount >= 3 { + randomizeVelocity() + sameDirectionBounceCount = 0 + lastBounceNormal = .zero + } + } + + layoutAvatar() + } +} diff --git a/QuickLocation/Section/Home/Bubble/BubbleKnowledgeVC.swift b/QuickLocation/Section/Home/Bubble/BubbleKnowledgeVC.swift new file mode 100644 index 00000000..115a420b --- /dev/null +++ b/QuickLocation/Section/Home/Bubble/BubbleKnowledgeVC.swift @@ -0,0 +1,25 @@ +// +// BubbleKnowledgeVC.swift +// QuickLocation +// + +import UIKit + +final class BubbleKnowledgeVC: BaseViewController { + + override var isNavigationBarHidden: Bool { true } + + private var rootView: BubbleKnowledgeView! + + override func loadView() { + rootView = BubbleKnowledgeView(frame: UIScreen.main.bounds) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + rootView.navView.onBackTapped = { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + } +} diff --git a/QuickLocation/Section/Home/Bubble/BubbleKnowledgeView.swift b/QuickLocation/Section/Home/Bubble/BubbleKnowledgeView.swift new file mode 100644 index 00000000..1d8d9a92 --- /dev/null +++ b/QuickLocation/Section/Home/Bubble/BubbleKnowledgeView.swift @@ -0,0 +1,147 @@ +// +// BubbleKnowledgeView.swift +// QuickLocation +// + +import UIKit + +final class BubbleKnowledgeView: UIView { + + lazy var navView = BaseNavigationView(title: "气泡知识") + + private let scrollView = UIScrollView() + private let contentView = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#FAFAFA") + setupUI() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func setupUI() { + addSubview(navView) + navView.layoutChain.top().edgesHorzontal().height(kNaviHeight) + + addSubview(scrollView) + scrollView.layoutChain.topToBottomOfView(navView).edgesHorzontal().bottom() + scrollView.addSubview(contentView) + contentView.layoutChain.edges().widthToView(scrollView) + + let sections: [(String, String, Bool)] = [ + ("1", "气泡基础知识", true), + ("2", "气泡持续时间", false), + ("3", "签到", false), + ("4", "弹出您的气泡", false) + ] + + var previous: UIView? + for item in sections { + let block = makeSection(index: item.0, title: item.1, withFeatureList: item.2) + contentView.addSubview(block) + if let previous { + block.layoutChain.topToBottomOfView(previous, offset: 20).edgesHorzontal(16) + } else { + block.layoutChain.top(16).edgesHorzontal(16) + } + previous = block + } + previous?.layoutChain.bottom(24) + } + + private func makeSection(index: String, title: String, withFeatureList: Bool) -> UIView { + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 16 + + let badge = UILabel() + badge.text = index + badge.font = .systemFont(ofSize: 14, weight: .bold) + badge.textColor = .white + badge.textAlignment = .center + badge.backgroundColor = UIColor(hexStr: "#16B3FF") + badge.layer.cornerRadius = 8 + badge.clipsToBounds = true + card.addSubview(badge) + badge.layoutChain.top(16).left(16).width(28).height(28) + + let titleLab = UILabel() + titleLab.text = title + titleLab.font = .systemFont(ofSize: 16, weight: .bold) + titleLab.textColor = UIColor(hexStr: "#293445") + card.addSubview(titleLab) + titleLab.layoutChain.leftToRightOfView(badge, offset: 10).centerY(badge) + + let body = UILabel() + body.numberOfLines = 0 + body.font = .systemFont(ofSize: 14, weight: .medium) + body.textColor = UIColor(hexStr: "#767676") + card.addSubview(body) + body.layoutChain.topToBottomOfView(badge, offset: 12).edgesHorzontal(16) + + switch index { + case "1": + body.text = "当您在气泡中时,圈子成员只能看到您创建气泡时的位置,而不是您的实时位置。安全功能仍然有效。" + case "2": + body.text = "气泡会在设定的时间到期,但您或圈子成员可以提前「弹出」气泡。" + case "3": + body.text = "虽然气泡有设定到期时间,但完成「签到」也会弹出气泡。" + case "4": + body.text = "点击「弹出气泡」可手动结束气泡,之后圈子将能看到您的精确位置。" + default: + break + } + + var bottomAnchor: UIView = body + if withFeatureList { + let list = makeFeatureList() + card.addSubview(list) + list.layoutChain.topToBottomOfView(body, offset: 12).edgesHorzontal(16).bottom(16) + bottomAnchor = list + } else { + body.layoutChain.bottom(16) + } + _ = bottomAnchor + return card + } + + private func makeFeatureList() -> UIView { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 0 + + let rows: [(String, String)] = [ + ("碰撞提醒", "开启"), + ("SOS警报", "开启"), + ("位置共享", "开启"), + ("低电量通知", "关闭"), + ("安全驾驶通知", "关闭"), + ("位置通知", "关闭") + ] + + for (i, row) in rows.enumerated() { + let line = UIView() + let title = UILabel() + title.text = row.0 + title.font = .systemFont(ofSize: 14, weight: .medium) + title.textColor = UIColor(hexStr: "#293445") + let status = UILabel() + status.text = row.1 + status.font = .systemFont(ofSize: 14, weight: .medium) + status.textColor = UIColor(hexStr: "#9CA3AF") + line.addSubview(title) + line.addSubview(status) + title.layoutChain.left().centerY().top(12).bottom(12) + status.layoutChain.right().centerY() + stack.addArrangedSubview(line) + if i < rows.count - 1 { + let sep = UIView() + sep.backgroundColor = UIColor(hexStr: "#F2F2F2") + sep.layoutChain.height(0.5) + stack.addArrangedSubview(sep) + } + } + return stack + } +} diff --git a/QuickLocation/Section/Home/Bubble/CreateBubbleDoneView.swift b/QuickLocation/Section/Home/Bubble/CreateBubbleDoneView.swift index d3604513..5d21c96d 100644 --- a/QuickLocation/Section/Home/Bubble/CreateBubbleDoneView.swift +++ b/QuickLocation/Section/Home/Bubble/CreateBubbleDoneView.swift @@ -2,8 +2,6 @@ // CreateBubbleDoneView.swift // QuickLocation // -// Created by 八条 on 2026/6/26. -// import UIKit import RxSwift @@ -13,9 +11,15 @@ class CreateBubbleDoneView: UIView { var disposeBag = DisposeBag() + let heroView = BubbleHeroView() + let popupBtn = UIButton(type: .custom) + private var countdownTimer: Timer? private var endDate: Date = Date() + private let clockImage = UIImageView() + private let timeLab = UILabel() + func startCountdown(endDate: Date) { self.endDate = endDate countdownTimer?.invalidate() @@ -38,150 +42,51 @@ class CreateBubbleDoneView: UIView { let seconds = Int(remaining) % 60 timeLab.text = String(format: "%02d:%02d:%02d", hours, minutes, seconds) } - - private func setupRx() { - - } private func setupUI() { - addSubview(titleLab) - addSubview(timeIcon) - addSubview(timeLab) - addSubview(messageView) - addSubview(iconView) - addSubview(scrollView) + addSubview(heroView) + addSubview(clockImage) + clockImage.addSubview(timeLab) addSubview(popupBtn) - titleLab.layoutChain - .top(15) + heroView.layoutChain + .top(33) + .leading(30) + .trailing(30) + .heightToWidth(1) + + clockImage.image = UIImage(named: "Bubble/clock") + clockImage.contentMode = .scaleAspectFit + clockImage.layoutChain + .topToBottomOfView(heroView, offset: 8) .centerX() - - timeLab.layoutChain - .topToBottomOfView(titleLab, offset: 12) - .centerX() - - timeIcon.layoutChain - .rightToLeftOfView(timeLab, offset: -8) - .centerY(timeLab) - - messageView.layoutChain - .topToBottomOfView(timeLab, offset: 25) - .left(79) - .right(30) - - iconView.layoutChain - .rightToLeftOfView(messageView, offset: -9) - .bottomToView(messageView) - .height(40) - .width(40) - - popupBtn.layoutChain - .width(240) - .height(50) - .centerX() - .bottom(kSafeBottomMargin + 20) - - scrollView.layoutChain - .topToBottomOfView(messageView, offset: 20) - .edgesHorzontal() - .bottomToTopOfView(popupBtn, offset: -20) + .width(200) + .height(120) + + timeLab.font = .systemFont(ofSize: 28, weight: .heavy) + timeLab.textColor = .white + timeLab.textAlignment = .center + timeLab.layoutChain.centerX(clockImage).centerY(clockImage, offset: 4) + + popupBtn.setTitle("弹出气泡", for: .normal) + popupBtn.setTitleColor(.white, for: .normal) + popupBtn.titleLabel?.font = FontManager.boboBold(18) + popupBtn.backgroundColor = UIColor(hexStr: "#16B3FF") + popupBtn.layer.cornerRadius = 16 + popupBtn.layoutChain.left(20).right(20).bottom(34).height(56) } - lazy var titleLab: UILabel = { - let label = UILabel() - label.text = "您的泡将在以下时间弹出:" - label.font = .systemFont(ofSize: 16, weight: .medium) - label.textColor = ThemeManager.shared.color.titleAuxColor - label.numberOfLines = 0 - return label - }() - - lazy var popupBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setTitle("弹出气泡", for: .normal) - btn.setTitleColor(.white, for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) - btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) - btn.cornerRadius = 25 - return btn - }() - - lazy var timeIcon: UIImageView = { - let view = UIImageView() - view.image = UIImage(named: "Bubble/time") - return view - }() - - lazy var timeLab: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 20, weight: .bold) - label.textColor = ThemeManager.shared.color.titleAuxColor - return label - }() - - lazy var iconView: UIImageView = { - let view = UIImageView(image: UIImage(named: "UserIcon/7")) - view.cornerRadius = 20 - return view - }() - - lazy var messageView: UIView = { - let view = UIView() - view.backgroundColor = UIColor(hexStr: "#EBF6F9") - view.cornerRadius = 10 - - view.addSubview(messageLab) - messageLab.layoutChain - .edgesHorzontal(18) - .edgesVertical(15) - - return view - }() - - lazy var messageLab: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 16, weight: .medium) - label.textColor = ThemeManager.shared.color.titleAuxColor - label.numberOfLines = 0 - return label - }() - - lazy var scrollView: UIScrollView = { - let view = UIScrollView() - view.backgroundColor = .clear - view.showsVerticalScrollIndicator = false - view.bounces = false - - let contentView = UIView() - contentView.backgroundColor = .clear - view.addSubview(contentView) - contentView.layoutChain.edges().widthToView(view) - - let textImgView = UIImageView() - textImgView.image = UIImage(named: "Bubble/text") - contentView.addSubview(textImgView) - textImgView.layoutChain - .edgesVertical() - .edgesHorzontal(30) - .heightToWidth(1450/630) - - return view - }() - override init(frame: CGRect) { super.init(frame: .zero) - self.isHidden = true backgroundColor = .white setupUI() - setupRx() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + deinit { countdownTimer?.invalidate() } - } diff --git a/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift b/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift new file mode 100644 index 00000000..6278fa35 --- /dev/null +++ b/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift @@ -0,0 +1,289 @@ +// +// CreateBubbleSetupView.swift +// QuickLocation +// + +import UIKit +import RxSwift +import RxCocoa +import SwiftDate + +/// 设置页:时长 picker + 同页底部通知 sheet +final class CreateBubbleSetupView: UIView { + + var disposeBag = DisposeBag() + let selectedHour = BehaviorRelay(value: 1) + var messageText: String = "" + + var onConfirmCreate: (() -> Void)? + var onSheetVisibilityChanged: ((Bool) -> Void)? + + private let hours = Array(1...6) + + let heroView = BubbleHeroView() + let confirmBtn = UIButton(type: .custom) + let sheetConfirmBtn = UIButton(type: .custom) + + private let subtitleLab = UILabel() + private let titleLab = UILabel() + private let pickerView = UIPickerView() + private let hourLab = UILabel() + + private let sheetOverlay = UIView() + private let sheetPanel = UIView() + private let pinIcon = UIImageView() + private let sheetTitleLab = UILabel() + private let messageView = UIView() + private let messageLab = UILabel() + private let previewAvatar = UIImageView() + private let tipStack = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + setupUI() + setupPicker() + setupSheet() + pickerView.selectRow(0, inComponent: 0, animated: false) + selectedHour.accept(hours[0]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func showNotifySheet(message: String) { + messageText = message + messageLab.text = message + sheetOverlay.isHidden = false + sheetOverlay.alpha = 0 + sheetPanel.transform = CGAffineTransform(translationX: 0, y: 40) + onSheetVisibilityChanged?(true) + UIView.animate(withDuration: 0.28, delay: 0, options: .curveEaseOut) { + self.sheetOverlay.alpha = 1 + self.sheetPanel.transform = .identity + } + } + + func dismissNotifySheet() { + onSheetVisibilityChanged?(false) + UIView.animate(withDuration: 0.22, delay: 0, options: .curveEaseIn) { + self.sheetOverlay.alpha = 0 + self.sheetPanel.transform = CGAffineTransform(translationX: 0, y: 40) + } completion: { _ in + self.sheetOverlay.isHidden = true + } + } + + var isSheetVisible: Bool { !sheetOverlay.isHidden && sheetOverlay.alpha > 0.01 } + + private func setupUI() { + addSubview(heroView) + addSubview(titleLab) + addSubview(subtitleLab) + addSubview(pickerView) + addSubview(hourLab) + addSubview(confirmBtn) + + heroView.layoutChain + .top(33) + .leading(30) + .trailing(30) + .heightToWidth(1) + + titleLab.text = "您会在气泡待多久?" + titleLab.font = FontManager.boboBold(30) + titleLab.textColor = UIColor(hexStr: "#293445") + titleLab.textAlignment = .center + titleLab.layoutChain.topToBottomOfView(heroView, offset: 25).centerX() + + subtitleLab.text = "设置气泡时间" + subtitleLab.font = .systemFont(ofSize: 18, weight: .bold) + subtitleLab.textColor = UIColor(hexStr: "#9CA3AF") + subtitleLab.layoutChain.topToBottomOfView(titleLab, offset: 4).centerX() + + pickerView.layoutChain + .topToBottomOfView(subtitleLab, offset: 12) + .edgesHorzontal(15) + .height(180) + + hourLab.text = "小时" + hourLab.font = .systemFont(ofSize: 16, weight: .medium) + hourLab.textColor = UIColor(hexStr: "#16B3FF") + hourLab.layoutChain.centerX(self, offset: 100).centerY(pickerView) + + confirmBtn.setTitle("确定", for: .normal) + confirmBtn.setTitleColor(.white, for: .normal) + confirmBtn.titleLabel?.font = FontManager.boboBold(18) + confirmBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + confirmBtn.layer.cornerRadius = 16 + confirmBtn.layoutChain.left(20).right(20).bottom(34).height(56) + } + + private func setupPicker() { + pickerView.delegate = self + pickerView.dataSource = self + pickerView.backgroundColor = .clear + } + + override func layoutSubviews() { + super.layoutSubviews() + clearPickerSelectionBackground() + } + + private func clearPickerSelectionBackground() { + pickerView.subviews.forEach { sub in + sub.backgroundColor = .clear + if sub.frame.height <= 1 { + sub.isHidden = true + } + } + } + + private func setupSheet() { + sheetOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.45) + sheetOverlay.isHidden = true + addSubview(sheetOverlay) + sheetOverlay.layoutChain.edges() + + sheetPanel.backgroundColor = .white + sheetPanel.layer.cornerRadius = 24 + sheetPanel.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + sheetOverlay.addSubview(sheetPanel) + sheetPanel.layoutChain.edgesHorzontal().bottom() + + pinIcon.image = UIImage(named: "Bubble/pin") + pinIcon.contentMode = .scaleAspectFit + sheetPanel.addSubview(pinIcon) + pinIcon.layoutChain.top(-22).centerX().width(44).height(44) + + sheetTitleLab.text = "通知您的圈子" + sheetTitleLab.font = .systemFont(ofSize: 18, weight: .bold) + sheetTitleLab.textColor = UIColor(hexStr: "#293445") + sheetTitleLab.textAlignment = .center + sheetPanel.addSubview(sheetTitleLab) + sheetTitleLab.layoutChain.top(28).centerX() + + messageView.backgroundColor = UIColor(hexStr: "#EBF6F9") + messageView.layer.cornerRadius = 12 + sheetPanel.addSubview(messageView) + messageView.addSubview(messageLab) + messageLab.font = .systemFont(ofSize: 15, weight: .medium) + messageLab.textColor = UIColor(hexStr: "#293445") + messageLab.numberOfLines = 0 + messageLab.layoutChain.edgesHorzontal(16).edgesVertical(14) + + previewAvatar.contentMode = .scaleAspectFill + previewAvatar.clipsToBounds = true + previewAvatar.layer.cornerRadius = 18 + previewAvatar.layer.borderWidth = 2 + previewAvatar.layer.borderColor = UIColor.white.cgColor + let avatar = AppContextManager.shared.avaterIcon + previewAvatar.image = avatar.size.width > 0 ? avatar : UIImage(named: "Common/default_avatar") + sheetPanel.addSubview(previewAvatar) + + messageView.layoutChain + .topToBottomOfView(sheetTitleLab, offset: 20) + .left(24) + .right(72) + previewAvatar.layoutChain + .right(24) + .bottomToView(messageView) + .width(40) + .height(40) + + tipStack.axis = .vertical + tipStack.spacing = 12 + sheetPanel.addSubview(tipStack) + tipStack.layoutChain + .topToBottomOfView(messageView, offset: 16) + .edgesHorzontal(24) + + [ + "您的圈子成员只会看到您在气泡中。", + "当您在气泡中,将不会共享您的确切位置。" + ].forEach { tipStack.addArrangedSubview(makeTipRow($0)) } + + sheetConfirmBtn.setTitle("确定", for: .normal) + sheetConfirmBtn.setTitleColor(.white, for: .normal) + sheetConfirmBtn.titleLabel?.font = FontManager.boboBold(18) + sheetConfirmBtn.backgroundColor = UIColor(hexStr: "#16B3FF") + sheetConfirmBtn.layer.cornerRadius = 16 + sheetPanel.addSubview(sheetConfirmBtn) + sheetConfirmBtn.layoutChain + .topToBottomOfView(tipStack, offset: 20) + .edgesHorzontal(20) + .height(56) + .bottom(24 + kSafeBottomMargin) + + let tap = UITapGestureRecognizer(target: self, action: #selector(tapMask)) + sheetOverlay.addGestureRecognizer(tap) + } + + private func makeTipRow(_ text: String) -> UIView { + let row = UIView() + let star = UIImageView(image: UIImage(systemName: "sparkle")) + star.tintColor = UIColor(hexStr: "#16B3FF") + star.contentMode = .scaleAspectFit + let lab = UILabel() + lab.text = text + lab.font = .systemFont(ofSize: 14, weight: .medium) + lab.textColor = UIColor(hexStr: "#767676") + lab.numberOfLines = 0 + row.addSubview(star) + row.addSubview(lab) + star.layoutChain.left().top(2).width(14).height(14) + lab.layoutChain.leftToRightOfView(star, offset: 8).right().top().bottom() + return row + } + + @objc private func tapMask(_ gesture: UITapGestureRecognizer) { + let point = gesture.location(in: sheetPanel) + if sheetPanel.bounds.contains(point) { return } + dismissNotifySheet() + } + + func buildMessage(for hours: Int) -> String { + let endDate = Calendar.current.date(byAdding: .hour, value: hours, to: Date()) ?? Date() + let timeStr = endDate.toFormat("HH:mm") + return "\(timeStr)之前,我会一直在这个区域。如果需要我帮忙,请给我发消息。" + } +} + +extension CreateBubbleSetupView: UIPickerViewDataSource, UIPickerViewDelegate { + func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + hours.count + } + + func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat { 44 } + + func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView { + let container = view as? UIView ?? UIView() + container.subviews.forEach { $0.removeFromSuperview() } + + let label = UILabel() + label.textAlignment = .center + label.font = .systemFont(ofSize: 20, weight: .medium) + let selected = row == pickerView.selectedRow(inComponent: component) + label.textColor = selected ? UIColor(hexStr: "#16B3FF") : UIColor(hexStr: "#999999") + label.text = "\(hours[row])" + container.addSubview(label) + label.layoutChain.center().width(60).height(36) + + if selected { + let capsule = UIView() + capsule.backgroundColor = UIColor(hexStr: "#E3F6FF") + capsule.layer.cornerRadius = 14 + container.insertSubview(capsule, at: 0) + capsule.layoutChain.center().width(130).height(36) + } + + return container + } + + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + selectedHour.accept(hours[row]) + pickerView.reloadAllComponents() + clearPickerSelectionBackground() + } +} diff --git a/QuickLocation/Section/Home/Bubble/CreateBubbleVC.swift b/QuickLocation/Section/Home/Bubble/CreateBubbleVC.swift index 9be9aee6..b3164e3a 100644 --- a/QuickLocation/Section/Home/Bubble/CreateBubbleVC.swift +++ b/QuickLocation/Section/Home/Bubble/CreateBubbleVC.swift @@ -2,8 +2,6 @@ // CreateBubbleVC.swift // QuickLocation // -// Created by 八条 on 2026/6/26. -// import UIKit import RxSwift @@ -18,58 +16,85 @@ class CreateBubbleVC: BaseViewController { rootView = CreateBubbleView(frame: UIScreen.main.bounds) view = rootView } - - private var doneBtn: UIButton { - rootView.createBubbleTipsView.doneBtn - } - - private var popupBtn: UIButton { - rootView.createBubbleDoneView.popupBtn - } override func viewDidLoad() { super.viewDidLoad() - - doneBtn.rx.tap.subscribe(onNext: { [weak self] in - guard let self = self else { return } - - guard let systemConfig = AppContextManager.shared.systemConfig, systemConfig.isIntercept == false else { - let hours = self.rootView.createBubbleTiemView.selectedHour.value - requestSetBubble(enable: true, keep_time: hours) - return - } - - if AppContextManager.shared.vip > 1 { - let hours = self.rootView.createBubbleTiemView.selectedHour.value - requestSetBubble(enable: true, keep_time: hours) - } - else { - CreateBubblePopView.show() - } - }).disposed(by: disposeBag) - - popupBtn.rx.tap.subscribe(onNext: { [weak self] in - guard let self = self else { return } - self.showConfirmPop(title: "温馨提醒", - message: "您确定要弹出这个气泡吗?", - confirmText: "爆裂", - confirmBlock: { - self.requestSetBubble(enable: false, keep_time: -1) - }, cancelText: "取消") - }).disposed(by: disposeBag) - - guard AppContextManager.shared.status == 2, let endDate = Defaults[\.bubbleKeepTimes] else { return } - self.rootView.navView.titleLabel.text = "活动气泡" - self.rootView.createBubbleDoneView.messageLab.text = self.rootView.createBubbleTipsView.messageText - self.rootView.createBubbleTipsView.isHidden = true - self.rootView.createBubbleDoneView.isHidden = false - self.rootView.createBubbleDoneView.startCountdown(endDate: endDate) + bind() + restoreActiveBubbleIfNeeded() } - + + private func bind() { + rootView.navView.onBackTapped = { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + + rootView.moreBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.navigationController?.pushViewController(BubbleKnowledgeVC(), animated: true) + }) + .disposed(by: disposeBag) + + rootView.setupView.confirmBtn.rx.tap + .subscribe(onNext: { [weak self] in + guard let self else { return } + let hours = self.rootView.setupView.selectedHour.value + let message = self.rootView.setupView.buildMessage(for: hours) + self.rootView.setupView.showNotifySheet(message: message) + }) + .disposed(by: disposeBag) + + rootView.setupView.onSheetVisibilityChanged = { [weak self] visible in + self?.rootView.updateNavTitleForSheet(visible) + } + + rootView.setupView.sheetConfirmBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.submitCreate() + }) + .disposed(by: disposeBag) + + rootView.doneView.popupBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.showConfirmPop( + title: "温馨提醒", + message: "您确定要弹出这个气泡吗?", + confirmText: "爆裂", + confirmBlock: { [weak self] in + self?.requestSetBubble(enable: false, keep_time: -1) + }, + cancelText: "取消" + ) + }) + .disposed(by: disposeBag) + } + + private func submitCreate() { + guard let systemConfig = AppContextManager.shared.systemConfig, systemConfig.isIntercept == false else { + let hours = rootView.setupView.selectedHour.value + requestSetBubble(enable: true, keep_time: hours) + return + } + + if AppContextManager.shared.vip > 1 { + let hours = rootView.setupView.selectedHour.value + requestSetBubble(enable: true, keep_time: hours) + } else { + CreateBubblePopView.show() + } + } + + private func restoreActiveBubbleIfNeeded() { + guard AppContextManager.shared.status == 2, + let endDate = Defaults[\.bubbleKeepTimes] else { return } + rootView.showDone(animated: false) + rootView.doneView.startCountdown(endDate: endDate) + } + private func requestSetBubble(enable: Bool, keep_time: Int) { DLToast.showLoading() - UserService.setBubble(enable: enable, keep_time: keep_time).subscribe(onNext: { response in + UserService.setBubble(enable: enable, keep_time: keep_time).subscribe(onNext: { [weak self] _ in DLToast.dismiss() + guard let self else { return } guard enable else { DLToast.show(text: "气泡已弹出") { Defaults[\.bubbleKeepTimes] = nil @@ -79,13 +104,11 @@ class CreateBubbleVC: BaseViewController { return } AppContextManager.shared.updateAccountStatus(2) - self.rootView.navView.titleLabel.text = "活动气泡" - self.rootView.createBubbleDoneView.messageLab.text = self.rootView.createBubbleTipsView.messageText + self.rootView.setupView.dismissNotifySheet() let endDate = Calendar.current.date(byAdding: .hour, value: keep_time, to: Date()) ?? Date() Defaults[\.bubbleKeepTimes] = endDate - self.rootView.createBubbleDoneView.startCountdown(endDate: endDate) - self.rootView.createBubbleTipsView.isHidden = true - self.rootView.createBubbleDoneView.isHidden = false - }).disposed(by: disposeBag) + self.rootView.doneView.startCountdown(endDate: endDate) + self.rootView.showDone(animated: true) + }, onError: { _ in }).disposed(by: disposeBag) } } diff --git a/QuickLocation/Section/Home/Bubble/CreateBubbleView.swift b/QuickLocation/Section/Home/Bubble/CreateBubbleView.swift index 0ce0921a..35ca0d89 100644 --- a/QuickLocation/Section/Home/Bubble/CreateBubbleView.swift +++ b/QuickLocation/Section/Home/Bubble/CreateBubbleView.swift @@ -2,8 +2,6 @@ // CreateBubbleView.swift // QuickLocation // -// Created by 八条 on 2026/6/26. -// import UIKit import RxSwift @@ -13,80 +11,83 @@ import SwiftDate class CreateBubbleView: UIView { var disposeBag = DisposeBag() - - var createBubbleTiemView: CreateBubbleTiemView = CreateBubbleTiemView() - var createBubbleTipsView: CreateBubbleTipsView = CreateBubbleTipsView() - var createBubbleDoneView: CreateBubbleDoneView = CreateBubbleDoneView() - - private func setupRx() { - createBubbleTiemView.onNextTap = { [weak self] in - guard let self = self else { return } - self.navView.titleLabel.text = "通知您的圈子" - let hours = self.createBubbleTiemView.selectedHour.value - let endDate = Calendar.current.date(byAdding: .hour, value: hours, to: Date()) ?? Date() - let timeStr = endDate.toFormat("HH:mm") - let message = "\(timeStr)之前,我会一直在这个区域。如果需要我帮忙,请给我发消息。" - self.createBubbleTipsView.messageLab.text = message - self.createBubbleTipsView.messageText = message - self.createBubbleTiemView.isHidden = true - self.createBubbleTipsView.isHidden = false - } - } - - private func setupUI() { - addSubview(createBubbleTiemView) - addSubview(createBubbleTipsView) - addSubview(createBubbleDoneView) - - addSubview(navBgView) - addSubview(navView) - - navBgView.layoutChain - .edges(excludingEdge: .bottom) - .heightToWidth(160/375) - - navView.layoutChain - .edges(excludingEdge: .bottom) - .height(kNaviHeight) - createBubbleTiemView.layoutChain - .topToBottomOfView(navView) - .edges(excludingEdge: .top) - - createBubbleTipsView.layoutChain - .topToBottomOfView(navView) - .edges(excludingEdge: .top) + let setupView = CreateBubbleSetupView() + let doneView = CreateBubbleDoneView() - createBubbleDoneView.layoutChain - .topToBottomOfView(navView) - .edges(excludingEdge: .top) - } + let moreBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(systemName: "ellipsis"), for: .normal) + btn.tintColor = UIColor(hexStr: "#293445") + btn.backgroundColor = UIColor.white.withAlphaComponent(0.9) + btn.layer.cornerRadius = 8 + btn.layer.borderWidth = 1 + btn.layer.borderColor = UIColor(hexStr: "#E8E8E8").cgColor + return btn + }() lazy var navBgView: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "Common/navBar_bg_2") iv.contentMode = .scaleAspectFill + iv.isHidden = true return iv }() - + lazy var navView: BaseNavigationView = { - let nav = BaseNavigationView(title: "设置气泡时间") - return nav + BaseNavigationView(title: "设置气泡时间") }() - - + override init(frame: CGRect) { - super.init(frame: .zero) + super.init(frame: frame) backgroundColor = .white setupUI() - setupRx() - createBubbleTiemView.isHidden = false - createBubbleTipsView.isHidden = true - createBubbleDoneView.isHidden = true + showSetup(animated: false) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + private func setupUI() { + addSubview(navBgView) + addSubview(navView) + addSubview(setupView) + addSubview(doneView) + + navBgView.layoutChain.edges(excludingEdge: .bottom).heightToWidth(160 / 375) + navView.layoutChain.edges(excludingEdge: .bottom).height(kNaviHeight) + navView.addRightButton(moreBtn) + moreBtn.layoutChain.width(36).height(36) + + setupView.layoutChain.topToBottomOfView(navView).edges(excludingEdge: .top) + doneView.layoutChain.topToBottomOfView(navView).edges(excludingEdge: .top) + } + + func showSetup(animated: Bool) { + doneView.isHidden = true + setupView.isHidden = false + moreBtn.isHidden = false + navView.titleLabel.text = setupView.isSheetVisible ? "通知您的圈子" : "设置气泡时间" + if animated { + setupView.alpha = 0 + UIView.animate(withDuration: 0.25) { self.setupView.alpha = 1 } + } + } + + func showDone(animated: Bool) { + setupView.isHidden = true + doneView.isHidden = false + moreBtn.isHidden = false + navView.titleLabel.text = "活动气泡" + doneView.heroView.reloadAvatar() + if animated { + doneView.alpha = 0 + UIView.animate(withDuration: 0.25) { self.doneView.alpha = 1 } + } + } + + func updateNavTitleForSheet(_ visible: Bool) { + navView.titleLabel.text = visible ? "通知您的圈子" : "设置气泡时间" + } } diff --git a/QuickLocation/Section/Home/GroupMemberView2.swift b/QuickLocation/Section/Home/GroupMemberView2.swift index d954d74f..6fb55ebe 100644 --- a/QuickLocation/Section/Home/GroupMemberView2.swift +++ b/QuickLocation/Section/Home/GroupMemberView2.swift @@ -77,7 +77,7 @@ class GroupMemberView2: UIView { private func setupCurrentUserDeviceInfo() { phoneModelIcon.image = UIImage(named: "Home/member_phone") - phoneModelLab.text = Self.localPhoneModelName() + phoneModelLab.text = UIDevice.modelName phoneModelLab.textColor = Self.batteryDefaultTextColor networkIcon.image = UIImage(named: "Home/member_wifi") @@ -131,20 +131,8 @@ class GroupMemberView2: UIView { // MARK: - Local device helpers - private static func localPhoneModelName() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let identifier = withUnsafePointer(to: &systemInfo.machine.0) { ptr in - String(cString: ptr) - } - return deviceModelMap[identifier] ?? "iPhone" - } - private static func localBatteryPercent() -> Int? { - UIDevice.current.isBatteryMonitoringEnabled = true - let level = UIDevice.current.batteryLevel - guard level >= 0 else { return nil } - return Int((level * 100).rounded()) + UIDevice.batteryPercent } private static func localNetworkStatusText() -> String { @@ -161,59 +149,6 @@ class GroupMemberView2: UIView { return "无网络" } - private static let deviceModelMap: [String: String] = [ - "iPhone10,1": "iPhone 8", - "iPhone10,4": "iPhone 8", - "iPhone10,2": "iPhone 8 Plus", - "iPhone10,5": "iPhone 8 Plus", - // iPhone X系列 - "iPhone10,3": "iPhone X", - "iPhone10,6": "iPhone X", - "iPhone11,2": "iPhone XS", - "iPhone11,4": "iPhone XS Max", - "iPhone11,6": "iPhone XS Max", - "iPhone11,8": "iPhone XR", - // iPhone11 - "iPhone12,1": "iPhone 11", - "iPhone12,3": "iPhone 11 Pro", - "iPhone12,5": "iPhone 11 Pro Max", - // iPhone12 - "iPhone13,1": "iPhone 12 mini", - "iPhone13,2": "iPhone 12", - "iPhone13,3": "iPhone 12 Pro", - "iPhone13,4": "iPhone 12 Pro Max", - // iPhone13 - "iPhone14,4": "iPhone 13 mini", - "iPhone14,5": "iPhone 13", - "iPhone14,2": "iPhone 13 Pro", - "iPhone14,3": "iPhone 13 Pro Max", - // iPhone14 - "iPhone14,7": "iPhone 14", - "iPhone14,8": "iPhone 14 Plus", - "iPhone15,2": "iPhone 14 Pro", - "iPhone15,3": "iPhone 14 Pro Max", - // iPhone15 - "iPhone15,4": "iPhone 15", - "iPhone15,5": "iPhone 15 Plus", - "iPhone16,1": "iPhone 15 Pro", - "iPhone16,2": "iPhone 15 Pro Max", - // iPhone16 - "iPhone17,1": "iPhone 16 Pro", - "iPhone17,2": "iPhone 16 Pro Max", - "iPhone17,3": "iPhone 16", - "iPhone17,4": "iPhone 16 Plus", - "iPhone17,5": "iPhone 16e", - // iPhone17 2025机型 - "iPhone18,1": "iPhone 17 Pro", - "iPhone18,2": "iPhone 17 Pro Max", - "iPhone18,3": "iPhone 17", - "iPhone18,4": "iPhone Air", - // Simulator - "i386": "Simulator", - "x86_64": "Simulator", - "arm64": "Simulator" - ] - private func setupRx() { NotificationCenter.default.addObserver( self, diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift index 96be3d7c..74e2c1e5 100644 --- a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift @@ -58,6 +58,7 @@ final class PhoneReportDetailView: UIView { let deviceCard = UIView() let deviceNameLab = UILabel() let storageLab = UILabel() + let storageBarTrack = UIView() let storageBar = UIProgressView(progressViewStyle: .default) let deviceImage = UIImageView(image: UIImage(named: "Home/phone_device")) @@ -65,14 +66,18 @@ final class PhoneReportDetailView: UIView { let screenSection = PhoneReportChartSectionView( title: "屏幕使用时间", summaryPrefix: "今日使用时长", - barColor: UIColor(hexStr: "#5EC8FF"), - highlightColor: UIColor(hexStr: "#2F9BFF") + summaryColor: UIColor(hexStr: "#00ADFE"), + barGradient: (UIColor(hexStr: "#C0EAFF"), UIColor(hexStr: "#ECF9FF")), + highlightGradient: (UIColor(hexStr: "#16B3FF"), UIColor(hexStr: "#9EDFFF")), + tipColor: UIColor(hexStr: "#20B6FF") ) let unlockSection = PhoneReportChartSectionView( title: "手机使用次数", summaryPrefix: "今日使用次数", - barColor: UIColor(hexStr: "#B7E86A"), - highlightColor: UIColor(hexStr: "#83DE00"), + summaryColor: UIColor(hexStr: "#68C025"), + barGradient: (UIColor(hexStr: "#DDFFA1"), UIColor(hexStr: "#F2FFDC")), + highlightGradient: (UIColor(hexStr: "#AAF52A"), UIColor(hexStr: "#E8FFC1")), + tipColor: UIColor(hexStr: "#86E73D"), valueSuffix: "次" ) @@ -95,26 +100,29 @@ final class PhoneReportDetailView: UIView { navView.layoutChain.top().edgesHorzontal().height(kNaviHeight) navView.addRightButton(switchGroupBtn) - addSubview(memberCV) - memberCV.layoutChain - .topToBottomOfView(navView, offset: 8) - .edgesHorzontal(15) - .height(100) - addSubview(scrollView) scrollView.showsVerticalScrollIndicator = false + scrollView.clipsToBounds = true + scrollView.delaysContentTouches = false + scrollView.canCancelContentTouches = true scrollView.layoutChain - .topToBottomOfView(memberCV, offset: 12) + .topToBottomOfView(navView) .edgesHorzontal() .bottom() scrollView.addSubview(contentView) contentView.layoutChain.edges().widthToView(scrollView) + contentView.addSubview(memberCV) + memberCV.layoutChain + .top(8) + .edgesHorzontal(15) + .height(100) + statusTitleLab.text = "手机状态" statusTitleLab.font = .systemFont(ofSize: 16, weight: .bold) statusTitleLab.textColor = UIColor(hexStr: "#293445") contentView.addSubview(statusTitleLab) - statusTitleLab.layoutChain.top().left(15) + statusTitleLab.layoutChain.topToBottomOfView(memberCV, offset: 12).left(15) statusCard.backgroundColor = .white statusCard.applyPhoneReportCardStyle(cornerRadius: 20) @@ -136,10 +144,10 @@ final class PhoneReportDetailView: UIView { batteryRing.layoutChain.top(23).centerX().width(100).height(100) estimateLab.font = .systemFont(ofSize: 12, weight: .medium) - estimateLab.textColor = UIColor(hexStr: "#6B7280") + estimateLab.textColor = UIColor(hexStr: "#293445") estimateLab.textAlignment = .center - estimateLab.backgroundColor = UIColor(hexStr: "#E8EEF5") - estimateLab.cornerRadius = 10 + estimateLab.backgroundColor = UIColor(hexStr: "#FAFAFA") + estimateLab.cornerRadius = 5 leftPanel.addSubview(estimateLab) estimateLab.layoutChain .topToBottomOfView(batteryRing, offset: 12) @@ -177,16 +185,24 @@ final class PhoneReportDetailView: UIView { deviceCard.addSubview(storageLab) storageLab.layoutChain.topToBottomOfView(deviceNameLab, offset: 4).left(10) - storageBar.progressTintColor = UIColor(hexStr: "#5EC8FF") - storageBar.trackTintColor = UIColor(hexStr: "#E5E7EB") - deviceCard.addSubview(storageBar) - storageBar.layoutChain + storageBarTrack.backgroundColor = UIColor(hexStr: "#E0E0E0") + storageBarTrack.layer.cornerRadius = 5 + storageBarTrack.clipsToBounds = true + deviceCard.addSubview(storageBarTrack) + storageBarTrack.layoutChain .topToBottomOfView(storageLab, offset: 6) .left(10) .right(60) - .height(4) + .height(10) .bottom(10) + storageBar.progressTintColor = UIColor(hexStr: "#16B3FF") + storageBar.trackTintColor = UIColor(hexStr: "#16B3FF", alpha: 0.3) + storageBarTrack.addSubview(storageBar) + storageBar.layoutChain + .edgesHorzontal(3) + .centerY() + deviceImage.contentMode = .scaleAspectFit deviceCard.addSubview(deviceImage) deviceImage.layoutChain.right(8).centerY().width(36).height(54) @@ -204,7 +220,7 @@ final class PhoneReportDetailView: UIView { } func apply(_ snap: PhoneReportDetailViewModel.StatusSnapshot) { - batteryRing.configure(percent: snap.batteryPercent, text: snap.batteryText) + batteryRing.configure(percent: snap.batteryPercent, text: snap.batteryText, isCharging: snap.isCharging) estimateLab.text = "预计使用\(snap.estimateText)" brightnessCard.setValue(snap.brightnessText) volumeCard.setValue(snap.volumeText) @@ -212,8 +228,8 @@ final class PhoneReportDetailView: UIView { deviceNameLab.text = snap.deviceText storageLab.text = snap.storageText storageBar.progress = Float(snap.storageRatio) - screenSection.setSummary("\(screenSection.summaryPrefix) \(snap.screenTodayText)") - unlockSection.setSummary("\(unlockSection.summaryPrefix) \(snap.unlockTodayText)次") + screenSection.setSummary(snap.screenTodayText) + unlockSection.setSummary("\(snap.unlockTodayText)次") screenSection.setBars(snap.screenBars) unlockSection.setBars(snap.unlockBars) } @@ -274,8 +290,9 @@ final class PhoneBatteryRingView: UIView { progress.path = path.cgPath } - func configure(percent: Int?, text: String) { + func configure(percent: Int?, text: String, isCharging: Bool = false) { valueLab.text = text + bolt.isHidden = !isCharging progress.strokeEnd = CGFloat(max(0, min(100, percent ?? 0))) / 100 } } @@ -298,12 +315,12 @@ final class PhoneStatusMiniCard: UIView { titleLab.textColor = UIColor(hexStr: "#AAAAAA") titleLab.textAlignment = .center addSubview(titleLab) - titleLab.layoutChain.leftToRightOfView(icon, offset: 4).right(5) + titleLab.layoutChain.leftToRightOfView(icon, offset: 4).right(5, relation: .greaterThanOrEqual).centerY(icon) valueLab.font = .systemFont(ofSize: 16, weight: .medium) valueLab.textColor = UIColor(hexStr: "#293445") valueLab.textAlignment = .center addSubview(valueLab) - valueLab.layoutChain.topToBottomOfView(icon, offset: 19).edgesHorzontal(2).bottom(16) + valueLab.layoutChain.topToBottomOfView(icon, offset: 10).edgesHorzontal(2) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -316,20 +333,33 @@ final class PhoneStatusMiniCard: UIView { final class PhoneReportChartSectionView: UIView { let summaryPrefix: String private let titleLab = UILabel() + private let summaryPrefixLab = UILabel() private let summaryLab = UILabel() private let dayBtn = UIButton(type: .system) private let weekBtn = UIButton(type: .system) private let chart = PhoneReportBarChartView() - private let barColor: UIColor - private let highlightColor: UIColor + private let summaryColor: UIColor + private let barGradient: (UIColor, UIColor) + private let highlightGradient: (UIColor, UIColor) + private let tipColor: UIColor private let valueSuffix: String var onModeChanged: ((PhoneReportDetailViewModel.ChartMode) -> Void)? - init(title: String, summaryPrefix: String, barColor: UIColor, highlightColor: UIColor, valueSuffix: String = "") { + init( + title: String, + summaryPrefix: String, + summaryColor: UIColor, + barGradient: (UIColor, UIColor), + highlightGradient: (UIColor, UIColor), + tipColor: UIColor, + valueSuffix: String = "" + ) { self.summaryPrefix = summaryPrefix - self.barColor = barColor - self.highlightColor = highlightColor + self.summaryColor = summaryColor + self.barGradient = barGradient + self.highlightGradient = highlightGradient + self.tipColor = tipColor self.valueSuffix = valueSuffix super.init(frame: .zero) titleLab.text = title @@ -344,26 +374,43 @@ final class PhoneReportChartSectionView: UIView { addSubview(card) card.layoutChain.topToBottomOfView(titleLab, offset: 10).edgesHorzontal().bottom().height(180) - summaryLab.font = .systemFont(ofSize: 13, weight: .medium) - summaryLab.textColor = UIColor(hexStr: "#293445") - card.addSubview(summaryLab) - summaryLab.layoutChain.top(14).left(14) + summaryPrefixLab.text = summaryPrefix + summaryPrefixLab.font = .systemFont(ofSize: 12, weight: .medium) + summaryPrefixLab.textColor = UIColor(hexStr: "#293445") + summaryPrefixLab.setContentCompressionResistancePriority(.required, for: .horizontal) + card.addSubview(summaryPrefixLab) + summaryPrefixLab.layoutChain.top(14).left(14) configureToggle(dayBtn, title: "天", selected: true) configureToggle(weekBtn, title: "周", selected: false) let toggle = UIStackView(arrangedSubviews: [weekBtn, dayBtn]) toggle.axis = .horizontal toggle.spacing = 0 - toggle.backgroundColor = UIColor(hexStr: "#F3F4F6") - toggle.cornerRadius = 12 - card.addSubview(toggle) - toggle.layoutChain.centerY(summaryLab).right(14).height(28) + + let toggleBg = UIView() + toggleBg.backgroundColor = UIColor(hexStr: "#EAEAEA") + toggleBg.layer.cornerRadius = 16 + toggleBg.clipsToBounds = true + toggleBg.addSubview(toggle) + toggle.layoutChain.edges(UIEdgeInsets(top: 3, left: 3, bottom: 3, right: 3)) + + card.addSubview(toggleBg) + toggleBg.layoutChain.centerY(summaryPrefixLab).right(14).height(32) + + summaryLab.font = .systemFont(ofSize: 12, weight: .bold) + summaryLab.textColor = summaryColor + summaryLab.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + card.addSubview(summaryLab) + summaryLab.layoutChain + .centerY(summaryPrefixLab) + .leftToRightOfView(summaryPrefixLab, offset: 4) + .rightToLeftOfView(toggleBg, offset: 8, relation: .lessThanOrEqual) dayBtn.addTarget(self, action: #selector(tapDay), for: .touchUpInside) weekBtn.addTarget(self, action: #selector(tapWeek), for: .touchUpInside) card.addSubview(chart) - chart.layoutChain.topToBottomOfView(summaryLab, offset: 16).edgesHorzontal(14).bottom(14) + chart.layoutChain.topToBottomOfView(summaryPrefixLab, offset: 16).edgesHorzontal(25).bottom(14) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -398,100 +445,174 @@ final class PhoneReportChartSectionView: UIView { } func setBars(_ bars: [PhoneReportDetailViewModel.ChartBar]) { - chart.configure(bars: bars, color: barColor, highlight: highlightColor, suffix: valueSuffix) + chart.configure( + bars: bars, + barGradient: barGradient, + highlightGradient: highlightGradient, + tipColor: tipColor, + suffix: valueSuffix + ) } } final class PhoneReportBarChartView: UIView { private var bars: [PhoneReportDetailViewModel.ChartBar] = [] - private var color: UIColor = .systemBlue - private var highlight: UIColor = .systemBlue + private var barGradient: (UIColor, UIColor) = (.systemBlue, .systemBlue) + private var highlightGradient: (UIColor, UIColor) = (.systemBlue, .systemBlue) + private var tipColor: UIColor = .systemBlue private var suffix = "" private var barLayers: [CALayer] = [] private var labelViews: [UILabel] = [] - private var tipLab = UILabel() + private var barFrames: [CGRect] = [] + private var selectedIndex: Int? + private var barSpacing: CGFloat = 0 + private var baselineY: CGFloat = 0 + private let tipLab = UILabel() + private let baseline = UIView() override init(frame: CGRect) { super.init(frame: frame) + clipsToBounds = false + isUserInteractionEnabled = true + baseline.backgroundColor = UIColor(hexStr: "#F2F2F2") + addSubview(baseline) tipLab.font = .systemFont(ofSize: 11, weight: .bold) tipLab.textColor = .white - tipLab.backgroundColor = UIColor(hexStr: "#2F9BFF") tipLab.textAlignment = .center - tipLab.cornerRadius = 8 + tipLab.cornerRadius = 6 tipLab.clipsToBounds = true tipLab.isHidden = true + tipLab.isUserInteractionEnabled = false addSubview(tipLab) + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) + addGestureRecognizer(tap) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func configure(bars: [PhoneReportDetailViewModel.ChartBar], color: UIColor, highlight: UIColor, suffix: String) { + func configure( + bars: [PhoneReportDetailViewModel.ChartBar], + barGradient: (UIColor, UIColor), + highlightGradient: (UIColor, UIColor), + tipColor: UIColor, + suffix: String + ) { + let previous = self.bars self.bars = bars - self.color = color - self.highlight = highlight + self.barGradient = barGradient + self.highlightGradient = highlightGradient + self.tipColor = tipColor self.suffix = suffix + let dataChanged = previous.count != bars.count + || zip(previous, bars).contains { $0.label != $1.label || $0.value != $1.value } + if dataChanged || selectedIndex == nil { + selectedIndex = bars.firstIndex(where: \.isToday) ?? (bars.isEmpty ? nil : bars.count - 1) + } setNeedsLayout() } + @objc private func handleTap(_ gesture: UITapGestureRecognizer) { + let point = gesture.location(in: self) + guard !barFrames.isEmpty else { return } + for (i, frame) in barFrames.enumerated() { + let hitX = frame.minX - barSpacing / 2 + let hitW = frame.width + barSpacing + let hit = CGRect(x: hitX, y: 0, width: hitW, height: max(baselineY, bounds.height)) + if hit.contains(point) { + guard selectedIndex != i else { return } + selectedIndex = i + setNeedsLayout() + return + } + } + } + override func layoutSubviews() { super.layoutSubviews() barLayers.forEach { $0.removeFromSuperlayer() } barLayers.removeAll() labelViews.forEach { $0.removeFromSuperview() } labelViews.removeAll() + barFrames.removeAll() tipLab.isHidden = true - guard !bars.isEmpty else { return } + guard !bars.isEmpty else { + baseline.isHidden = true + return + } + baseline.isHidden = false let maxVal = max(bars.map(\.value).max() ?? 1, 1) let count = bars.count let labelH: CGFloat = 16 let chartH = bounds.height - labelH - 24 - let spacing: CGFloat = 8 - let barW = max(8, (bounds.width - spacing * CGFloat(count - 1)) / CGFloat(count)) + let barW: CGFloat = 16 + barSpacing = count > 1 + ? max(0, (bounds.width - barW * CGFloat(count)) / CGFloat(count - 1)) + : 0 + baselineY = bounds.height - labelH + baseline.frame = CGRect(x: 0, y: baselineY - 0.5, width: bounds.width, height: 1) + + if selectedIndex == nil || selectedIndex! >= count { + selectedIndex = bars.firstIndex(where: \.isToday) ?? count - 1 + } for (i, bar) in bars.enumerated() { let ratio = CGFloat(bar.value) / CGFloat(maxVal) let h = max(4, chartH * max(0.05, ratio)) - let x = CGFloat(i) * (barW + spacing) - let y = bounds.height - labelH - h - let layer = CALayer() - layer.frame = CGRect(x: x, y: y, width: barW, height: h) - layer.backgroundColor = (bar.isToday ? highlight : color.withAlphaComponent(0.35)).cgColor - layer.cornerRadius = 4 - self.layer.addSublayer(layer) - barLayers.append(layer) + let x = CGFloat(i) * (barW + barSpacing) + let y = baselineY - h + let barFrame = CGRect(x: x, y: y, width: barW, height: h) + barFrames.append(barFrame) + + let isSelected = i == selectedIndex + let gradient = CAGradientLayer() + gradient.frame = barFrame + let colors = isSelected ? highlightGradient : barGradient + gradient.colors = [colors.0.cgColor, colors.1.cgColor] + gradient.startPoint = CGPoint(x: 0.5, y: 0) + gradient.endPoint = CGPoint(x: 0.5, y: 1) + gradient.cornerRadius = 4 + layer.insertSublayer(gradient, below: tipLab.layer) + barLayers.append(gradient) let lab = UILabel() lab.text = bar.label lab.font = .systemFont(ofSize: 10, weight: .medium) lab.textColor = UIColor(hexStr: "#9CA3AF") lab.textAlignment = .center - lab.frame = CGRect(x: x - 6, y: bounds.height - labelH, width: barW + 12, height: labelH) - addSubview(lab) + lab.frame = CGRect(x: x - 10, y: bounds.height - labelH, width: barW + 20, height: labelH) + insertSubview(lab, belowSubview: tipLab) labelViews.append(lab) - if bar.isToday, bar.value > 0 { - let hours = bar.value / 3600 - let mins = (bar.value % 3600) / 60 - let text: String - if suffix == "次" { - text = "\(bar.value)次" - } else if hours > 0 { - text = mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" - } else { - text = "\(mins)m" - } - tipLab.text = " \(text) " + if isSelected { + tipLab.text = tipText(for: bar) + tipLab.backgroundColor = tipColor tipLab.sizeToFit() + let tipW = tipLab.bounds.width + 5 + 5 + let tipH = tipLab.bounds.height + 4 + 4 + // 与柱子水平居中,允许超出 chart 左右边界 tipLab.frame = CGRect( - x: max(0, x + barW / 2 - tipLab.bounds.width / 2 - 6), - y: max(0, y - 22), - width: tipLab.bounds.width + 12, - height: 18 + x: x + barW / 2 - tipW / 2, + y: max(0, y - tipH - 4), + width: tipW, + height: tipH ) - tipLab.backgroundColor = highlight tipLab.isHidden = false } } + bringSubviewToFront(tipLab) + } + + private func tipText(for bar: PhoneReportDetailViewModel.ChartBar) -> String { + if suffix == "次" { + return "\(bar.value)次" + } + let hours = bar.value / 3600 + let mins = (bar.value % 3600) / 60 + if hours > 0 { + return mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" + } + return "\(mins)m" } } diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift index 7c92f1d2..4e7fae57 100644 --- a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift @@ -26,9 +26,10 @@ final class PhoneReportDetailViewModel { let isToday: Bool } - struct StatusSnapshot { + struct StatusSnapshot { let batteryText: String let batteryPercent: Int? + let isCharging: Bool let estimateText: String let brightnessText: String let volumeText: String @@ -46,6 +47,7 @@ final class PhoneReportDetailViewModel { StatusSnapshot( batteryText: batteryText, batteryPercent: batteryPercent, + isCharging: isCharging, estimateText: estimateText, brightnessText: brightnessText, volumeText: volumeText ?? self.volumeText, @@ -73,6 +75,7 @@ final class PhoneReportDetailViewModel { private var volumeObservation: NSKeyValueObservation? init(members: [GroupMemberModel], selectedUserId: String) { + UIDevice.current.isBatteryMonitoringEnabled = true self.members = members self.selectedMemberId = BehaviorRelay(value: selectedUserId) self.snapshot = BehaviorRelay(value: Self.emptySnapshot()) @@ -114,6 +117,20 @@ final class PhoneReportDetailViewModel { }) .disposed(by: disposeBag) + // 与系统状态栏同步:电量 / 充电状态变化时刷新 + let batteryCenter = NotificationCenter.default + Observable.merge( + batteryCenter.rx.notification(UIDevice.batteryLevelDidChangeNotification), + batteryCenter.rx.notification(UIDevice.batteryStateDidChangeNotification) + ) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] _ in + guard let self else { return } + guard self.selectedMemberId.value == AppContextManager.shared.userId else { return } + self.refresh(userId: self.selectedMemberId.value) + }) + .disposed(by: disposeBag) + observeSystemVolume() } @@ -143,16 +160,13 @@ final class PhoneReportDetailViewModel { private func makeSelfSnapshot() -> StatusSnapshot { let mgr = UnlockCountManager.shared - UIDevice.current.isBatteryMonitoringEnabled = true - let batteryLevel = UIDevice.current.batteryLevel - let batteryValid = batteryLevel >= 0 - let battery = Int(batteryLevel * 100) + let batteryPercent = UIDevice.batteryPercent + let isCharging = UIDevice.isBatteryCharging let screenSec = mgr.todayScreenTimeSeconds let unlock = mgr.todayCount let usage = mgr.todayAppUsageCount - let batteryText = batteryValid ? "\(battery)%" : placeholder - let batteryPercent: Int? = batteryValid ? battery : nil + let batteryText = batteryPercent.map { "\($0)%" } ?? placeholder let brightnessText = Self.brightnessText() ?? placeholder let volumeText = Self.volumeText() ?? placeholder let wifiText = Self.wifiSSID() ?? placeholder @@ -174,11 +188,12 @@ final class PhoneReportDetailViewModel { return StatusSnapshot( batteryText: batteryText, batteryPercent: batteryPercent, + isCharging: isCharging, estimateText: placeholder, brightnessText: brightnessText, volumeText: volumeText, wifiText: wifiText, - deviceText: UIDevice.current.model, + deviceText: UIDevice.modelName, storageText: storageText, storageRatio: storageRatio, screenTodayText: screenTodayText, @@ -195,6 +210,7 @@ final class PhoneReportDetailViewModel { return StatusSnapshot( batteryText: hasBattery ? "\(battery)%" : placeholder, batteryPercent: hasBattery ? battery : nil, + isCharging: false, estimateText: placeholder, brightnessText: placeholder, volumeText: placeholder, @@ -214,6 +230,7 @@ final class PhoneReportDetailViewModel { StatusSnapshot( batteryText: "**", batteryPercent: nil, + isCharging: false, estimateText: "**", brightnessText: "**", volumeText: "**", @@ -295,68 +312,145 @@ final class PhoneReportDetailViewModel { } } - private static func storageText() -> String? { + /// 与「设置 → 通用 → iPhone 存储空间」一致:十进制 GB + ImportantUsage 可用容量 + private static func storageBytes() -> (used: Int64, total: Int64)? { + let url = URL(fileURLWithPath: NSHomeDirectory()) + let keys: Set = [ + .volumeTotalCapacityKey, + .volumeAvailableCapacityForImportantUsageKey + ] guard - let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()), - let total = attrs[.systemSize] as? NSNumber, - let free = attrs[.systemFreeSize] as? NSNumber + let values = try? url.resourceValues(forKeys: keys), + let total = values.volumeTotalCapacity, + let available = values.volumeAvailableCapacityForImportantUsage, + total > 0 else { return nil } - let used = total.doubleValue - free.doubleValue - let usedGB = used / 1_073_741_824 - let totalGB = total.doubleValue / 1_073_741_824 - return String(format: "%.1fGB/%.0fGB", usedGB, totalGB) + let used = max(0, Int64(total) - available) + return (used, Int64(total)) + } + + private static func formatStorageGB(_ bytes: Int64, fractionDigits: Int) -> String { + // Settings 使用十进制(1GB = 1e9),128G 机型会显示约 128GB 而非 119GB + let gb = Double(bytes) / 1_000_000_000 + return String(format: "%.\(fractionDigits)fGB", gb) + } + + private static func storageText() -> String? { + guard let info = storageBytes() else { return nil } + let usedText = formatStorageGB(info.used, fractionDigits: 1) + let totalText = formatStorageGB(info.total, fractionDigits: 0) + return "\(usedText)/\(totalText)" } private static func storageRatio() -> CGFloat? { - guard - let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()), - let total = attrs[.systemSize] as? NSNumber, - let free = attrs[.systemFreeSize] as? NSNumber, - total.doubleValue > 0 - else { return nil } - return CGFloat((total.doubleValue - free.doubleValue) / total.doubleValue) + guard let info = storageBytes(), info.total > 0 else { return nil } + return CGFloat(Double(info.used) / Double(info.total)) } private static func screenBars(from mgr: UnlockCountManager, mode: ChartMode) -> [ChartBar] { - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date()) - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd" let byDay = Defaults[\.screenTimeByDay] - - return (0..<7).map { offset -> ChartBar in - let dayOffset = offset - 6 - let date = calendar.date(byAdding: .day, value: dayOffset, to: today) ?? today - let key = formatter.string(from: date) - let label = formatter.string(from: date).suffix(5).replacingOccurrences(of: "-", with: "/") - let value: Int - if dayOffset == 0 { - value = mgr.todayScreenTimeSeconds - } else { - value = byDay[key] ?? 0 - } - // week mode: keep same day bars for now (UI toggle still works) - _ = mode - return ChartBar(label: String(label), value: value, isToday: dayOffset == 0) + let todayValue = mgr.todayScreenTimeSeconds + switch mode { + case .day: + return dayBars(byDay: byDay, todayValue: todayValue, labelStyle: .monthDaySlash) + case .week: + return weekBars(byDay: byDay, todayValue: todayValue) } } private static func unlockBars(unlock: Int, mode: ChartMode) -> [ChartBar] { + var byDay = Defaults[\.unlockCountByDay] + let todayKey = dayKey(for: Date()) + byDay[todayKey] = unlock + switch mode { + case .day: + return dayBars(byDay: byDay, todayValue: unlock, labelStyle: .monthDay) + case .week: + return weekBars(byDay: byDay, todayValue: unlock) + } + } + + private enum DayLabelStyle { + case monthDaySlash // 08/04 + case monthDay // 8/4 + } + + private static func dayKey(for date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar.current + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private static func dayBars( + byDay: [String: Int], + todayValue: Int, + labelStyle: DayLabelStyle + ) -> [ChartBar] { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) - let formatter = DateFormatter() - formatter.dateFormat = "M/d" + let labelFormatter = DateFormatter() + labelFormatter.locale = Locale(identifier: "en_US_POSIX") + switch labelStyle { + case .monthDaySlash: + labelFormatter.dateFormat = "MM/dd" + case .monthDay: + labelFormatter.dateFormat = "M/d" + } + return (0..<7).map { offset -> ChartBar in let dayOffset = offset - 6 let date = calendar.date(byAdding: .day, value: dayOffset, to: today) ?? today + let key = dayKey(for: date) let isToday = dayOffset == 0 - _ = mode + let value = isToday ? todayValue : (byDay[key] ?? 0) return ChartBar( - label: formatter.string(from: date), - value: isToday ? unlock : 0, + label: labelFormatter.string(from: date), + value: value, isToday: isToday ) } } + + /// 近 7 周:每周一根柱,值为该周内按日数据之和;label 为周起始日 M/d + private static func weekBars(byDay: [String: Int], todayValue: Int) -> [ChartBar] { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + guard let currentWeek = calendar.dateInterval(of: .weekOfYear, for: today) else { + return dayBars(byDay: byDay, todayValue: todayValue, labelStyle: .monthDay) + } + let labelFormatter = DateFormatter() + labelFormatter.locale = Locale(identifier: "en_US_POSIX") + labelFormatter.dateFormat = "M/d" + let todayKey = dayKey(for: today) + + return (0..<7).map { offset -> ChartBar in + let weekOffset = offset - 6 + let weekStart = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: currentWeek.start) + ?? currentWeek.start + let weekInterval = calendar.dateInterval(of: .weekOfYear, for: weekStart) + ?? DateInterval(start: weekStart, duration: 7 * 24 * 3600) + + var sum = 0 + var cursor = weekInterval.start + while cursor < weekInterval.end { + let key = dayKey(for: cursor) + if key == todayKey { + sum += todayValue + } else { + sum += byDay[key] ?? 0 + } + guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = next + } + + return ChartBar( + label: labelFormatter.string(from: weekInterval.start), + value: sum, + isToday: weekOffset == 0 + ) + } + } } diff --git a/QuickLocation/Section/Home/SearchLocation/SearchLocationHeaderView.swift b/QuickLocation/Section/Home/SearchLocation/SearchLocationHeaderView.swift new file mode 100644 index 00000000..e6063d32 --- /dev/null +++ b/QuickLocation/Section/Home/SearchLocation/SearchLocationHeaderView.swift @@ -0,0 +1,164 @@ +// +// SearchLocationHeaderView.swift +// QuickLocation +// + +import UIKit + +/// 查位置页共用顶部:hero + 使用人数 + 双标题 + 跑马灯 +final class SearchLocationHeaderView: UIView { + + let usageLab = UILabel() + let headlineLab = UILabel() + let subheadLab = UILabel() + let marqueeScrollView = UIScrollView() + + /// 猫插画 + 装饰合图;有切图时设 image 并清空 backgroundColor + let heroImageView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupMarquee() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func startMarqueeAnimation() { + marqueeScrollView.layer.removeAllAnimations() + marqueeScrollView.contentOffset.x = 0 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self = self else { return } + let contentW = self.marqueeScrollView.contentSize.width + guard contentW > self.marqueeScrollView.bounds.width else { return } + let duration = contentW / 100 + UIView.animate(withDuration: duration, delay: 0, options: [.curveLinear, .repeat]) { + self.marqueeScrollView.contentOffset.x = contentW - self.marqueeScrollView.bounds.width + } + } + } + + private func setupUI() { + addSubview(heroImageView) + addSubview(usageLab) + addSubview(headlineLab) + addSubview(subheadLab) + addSubview(marqueeScrollView) + + heroImageView.backgroundColor = UIColor(hexStr: "#FFD54F") + heroImageView.contentMode = .scaleAspectFit + heroImageView.layoutChain + .top() + .edgesHorzontal(24) + .heightToWidth(220 / 327) + + let count = Int.random(in: 8000...15000) + let usageText = "有 \(count) 人正在使用此功能" + let attr = NSMutableAttributedString(string: usageText) + if let range = usageText.range(of: "\(count)") { + attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), + range: NSRange(range, in: usageText)) + } + usageLab.attributedText = attr + usageLab.font = .systemFont(ofSize: 14, weight: .medium) + usageLab.textColor = UIColor(hexStr: "#999999") + usageLab.textAlignment = .center + usageLab.layoutChain + .topToBottomOfView(heroImageView, offset: 8) + .centerX() + + headlineLab.text = "实时查看TA的位置" + headlineLab.font = FontManager.boboBold(30) + headlineLab.textColor = UIColor(hexStr: "#293445") + headlineLab.textAlignment = .center + headlineLab.layoutChain + .topToBottomOfView(usageLab, offset: 16) + .centerX() + + subheadLab.text = "为你安心守护出行安全" + subheadLab.font = .systemFont(ofSize: 18, weight: .bold) + subheadLab.textColor = UIColor(hexStr: "#293445") + subheadLab.textAlignment = .center + subheadLab.layoutChain + .topToBottomOfView(headlineLab, offset: 6) + .centerX() + + marqueeScrollView.backgroundColor = .clear + marqueeScrollView.showsHorizontalScrollIndicator = false + marqueeScrollView.isScrollEnabled = false + marqueeScrollView.layoutChain + .topToBottomOfView(subheadLab, offset: 12) + .edgesHorzontal() + .height(36) + .bottom() + } + + private func randomPhoneNumber() -> String { + let prefixes = [ + "134","135","136","137","138","139","147","150","151","152","157","158","159","178","182","183","184","187","188","198", + "130","131","132","145","155","156","166","175","176","185","186","196", + "133","149","153","173","177","180","181","189","191","199" + ] + let prefix = prefixes.randomElement()! + var suffix = "" + for _ in 0..<8 { suffix.append("\(Int.random(in: 0...9))") } + return prefix + suffix + } + + private func setupMarquee() { + let surnames = ["张", "李", "王", "陈", "刘", "杨", "赵", "黄", "周", "吴", + "林", "何", "马", "胡", "郑", "梁", "谢", "宋", "唐", "韩"] + let container = UIView() + container.backgroundColor = .clear + marqueeScrollView.addSubview(container) + + var prevView: UIView? + for _ in 0..<10 { + let iconIndex = Int.random(in: 1...15) + let surname = surnames.randomElement() ?? "张" + let maskedName = "\(surname)**" + let phone = randomPhoneNumber() + let maskedPhone = phone.prefix(3) + "******" + phone.suffix(2) + + let itemView = UIView() + itemView.backgroundColor = UIColor(hexStr: "#EFF9FF") + itemView.cornerRadius = 8 + container.addSubview(itemView) + + let avatar = UIImageView(image: UIImage(named: "UserIcon/\(iconIndex)")) + avatar.contentMode = .scaleAspectFill + avatar.backgroundColor = UIColor(hexStr: "#E0E0E0") + avatar.cornerRadius = 12 + avatar.clipsToBounds = true + itemView.addSubview(avatar) + + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .medium) + let fullText = "\(maskedName) 定位到了 \(maskedPhone)" + let attr = NSMutableAttributedString(string: fullText) + attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), + range: NSRange(fullText.range(of: maskedName)!, in: fullText)) + if let phoneRange = fullText.range(of: maskedPhone) { + attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), + range: NSRange(phoneRange, in: fullText)) + } + label.attributedText = attr + itemView.addSubview(label) + + avatar.layoutChain.left(10).centerY().width(24).height(24) + label.layoutChain.leftToRightOfView(avatar, offset: 6).centerY().right(10) + itemView.layoutChain.centerY().height(30) + + if let prev = prevView { + itemView.layoutChain.leftToRightOfView(prev, offset: 16) + } else { + itemView.layoutChain.left() + } + prevView = itemView + } + + if let last = prevView { + container.layoutChain.edges().heightToView(marqueeScrollView).rightToView(last) + } + } +} diff --git a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift index f08f0818..7efe47ca 100644 --- a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift +++ b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift @@ -2,138 +2,153 @@ // SearchLocationResultVC.swift // QuickLocation // -// Created by 八条 on 2026/6/27. -// import UIKit import RxSwift import RxCocoa -import Lottie +import ObjectMapper +import URLNavigator class SearchLocationResultVC: BaseViewController { - + fileprivate var rootView: SearchLocationResultView! override func loadView() { rootView = SearchLocationResultView(frame: UIScreen.main.bounds) view = rootView } - + private let phone: String private let code: Int - private let memberData: [String : Any] + private let memberData: [String: Any] private var groupModel: GroupModel? override func viewDidLoad() { super.viewDidLoad() - rootView.unlockVipView.isHidden = AppContextManager.shared.vip > 1 - AppContextManager.shared.vip > 1 ? nil : rootView.unlockVipLottieView.play() + let isVip = AppContextManager.shared.vip > 1 + rootView.unlockVipView.isHidden = isVip + rootView.scrollView.isHidden = !isVip reactiveAction() requestPhoneArea() } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + rootView.startMarqueeAnimation() + } + private func reactiveAction() { - rootView.successBtn.rx.tap.subscribe(onNext: { _ in - if self.code == 0 { // 去查看 + rootView.actionBtn.rx.tap.subscribe(onNext: { _ in + switch self.code { + case 0: NotificationCenter.default.post(name: .ShowMemberLocationNotification, object: nil, userInfo: self.memberData) - AppRouter.shared.popToRoot() - } - else { // 邀请Ta → 弹出圈子选择 - guard let model = self.groupModel else { - self.requestGroupInfo(showPicker: true) - return + self.navigationController?.popToRootViewController(animated: true) + if let tab = self.tabBarController as? MainTabBarController + ?? UIViewController.topMost?.tabBarController as? MainTabBarController { + tab.selectTab(at: 0) } - GroupChooseView.show(groupModel: model) { _ in } + case 20004: + SharePopView.show() + case 20005: + self.showGroupPicker() + default: + break } }).disposed(by: disposeBag) - - rootView.inviteBtn.rx.tap.subscribe(onNext: { _ in - guard let model = self.groupModel else { - self.requestGroupInfo(showPicker: true) - return - } - GroupChooseView.show(groupModel: model) { _ in } - }).disposed(by: disposeBag) } - + + private func showGroupPicker() { + guard let model = groupModel else { + requestGroupInfo(showPicker: true) + return + } + presentGroupPicker(with: model) + } + + private func presentGroupPicker(with model: GroupModel) { + GroupListPopView.show(groupModel: model) { groupKey in + guard let key = groupKey, + let groupInfo = model.groups.first(where: { $0.group_key == key })?.toJSON() else { return } + AppRouter.push(Route.inviteJoin, userInfo: ["groupInfo": groupInfo]) + } + } + + private func shareApp() { + let text = "快来极速定位,实时守护家人出行安全" + let activity = UIActivityViewController(activityItems: [text], applicationActivities: nil) + present(activity, animated: true) + } + private func handleCode() { guard AppContextManager.shared.vip > 1 else { return } - - var fileName = "" - var message = "" - var tips = "" + + rootView.actionBtn.isHidden = true + rootView.inviteView.isHidden = true + switch code { - case 0: // 圈子成员 - fileName = "phone_search_success" - message = "定位成功" - tips = "点击去查看可获得具体位置" - rootView.inviteView.isHidden = false - rootView.successTipsLab.text = "已获得具体位置" - rootView.successBtn.setTitle("去查看", for: .normal) - rootView.phoneLab.text = phone - rootView.successLottieView.play() - case 20004: // 未注册 - fileName = "phone_search_fail" - message = "此用户未注册" - tips = "根据最新的相关法规,需用户双方均安装该APP才可实现定位,未安装APP的用户将不再提供具体定位功能。" - rootView.inviteBtn.isHidden = false - case 20005: // 不在一个圈子里 - fileName = "phone_search_success" - message = "定位成功" - tips = "根据最新的相关法规,您查询的用户与您不在同一个圈子时, 不再提供具体的位置定位,邀请ta加入你的圈子后可随时查看位置。" - rootView.inviteView.isHidden = false - rootView.successTipsLab.text = "邀请加入圈子后分享位置" - rootView.successBtn.setTitle("邀请他", for: .normal) - rootView.phoneLab.text = phone - rootView.successLottieView.play() + case 0: + rootView.configureResult( + status: "定位成功", + disclaimer: "点击去查看可获得具体位置", + showInviteRow: false + ) + rootView.actionBtn.setTitle("去查看", for: .normal) + rootView.actionBtn.isHidden = false + case 20004: + rootView.configureResult( + status: "用户未注册", + disclaimer: "根据最新的相关法规,需用户双方均安装该APP才可实现定位,未安装APP的用户将不再提供具体定位功能。", + showInviteRow: false + ) + rootView.actionBtn.setTitle("分享", for: .normal) + rootView.actionBtn.isHidden = false + case 20005: + rootView.configureResult( + status: "定位成功", + disclaimer: "根据最新的相关法规,您查询的用户与您不在同一个圈子时, 不再提供具体的位置定位,邀请ta加入你的圈子后可随时查看位置。", + showInviteRow: false + ) + rootView.actionBtn.setTitle("邀请TA加入", for: .normal) + rootView.actionBtn.isHidden = false requestGroupInfo() default: break } - - if let path = Bundle.main.path(forResource: fileName, ofType: "json") { - self.rootView.normalLottieView.animation = LottieAnimation.filepath(path) - self.rootView.normalLottieView.play() - } - self.rootView.messageLab.text = message - self.rootView.tipsLab.text = tips } - - // MARK: - 手机号归属地 + private func requestPhoneArea() { SystemService.phoneArea(phone: phone).subscribe(onNext: { response in guard let data = response.data else { return } self.handleCode() let province = data["Province"].safeString let city = data["City"].safeString - self.rootView.phoneAreaLab.text = "\(province)·\(city)" - self.rootView.unlockVipPhoneAreaLab.text = "\(province)·\(city)" + let area = "\(province)·\(city)" + self.rootView.phoneAreaLab.text = area + self.rootView.unlockVipPhoneAreaLab.text = area }).disposed(by: disposeBag) } - - // MARK: - 圈子列表 + private func requestGroupInfo(showPicker: Bool = false) { GroupService.groupInfo().subscribe { [weak self] response in guard let self = self, let model = response.model else { return } self.groupModel = model if showPicker { - GroupChooseView.show(groupModel: model) { _ in } + self.presentGroupPicker(with: model) } }.disposed(by: disposeBag) } - - // MARK: - Init - init(phone: String, code: Int, memberData: [String : Any] = [:]) { + + init(phone: String, code: Int, memberData: [String: Any] = [:]) { self.phone = phone self.code = code self.memberData = memberData super.init(nibName: nil, bundle: nil) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift index cf0c2494..3c3c6116 100644 --- a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift +++ b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift @@ -2,8 +2,6 @@ // SearchLocationResultView.swift // QuickLocation // -// Created by 八条 on 2026/6/27. -// import UIKit import RxSwift @@ -11,368 +9,301 @@ import RxCocoa import Lottie class SearchLocationResultView: UIView { - + var disposeBag = DisposeBag() - - private func setupRx() { - } - + + let headerView = SearchLocationHeaderView() + private func setupUI() { addSubview(unlockVipView) addSubview(navBgView) addSubview(navView) - - addSubview(normalView) - addSubview(successLottieView) - + addSubview(scrollView) + navBgView.layoutChain .edges(excludingEdge: .bottom) - .heightToWidth(160/375) - + .heightToWidth(160 / 375) + navView.layoutChain .edges(excludingEdge: .bottom) .height(kNaviHeight) - + unlockVipView.layoutChain.edges() - - successLottieView.layoutChain.edges() - - normalView.layoutChain + + scrollView.layoutChain .topToBottomOfView(navView) .edges(excludingEdge: .top) } - - // MARK: - Views - + + func startMarqueeAnimation() { + headerView.startMarqueeAnimation() + } + + func configureResult(status: String, disclaimer: String, showInviteRow: Bool) { + statusPillLab.text = status + disclaimerLab.text = disclaimer + disclaimerLab.textAlignment = disclaimer.contains("去查看") ? .center : .left + disclaimerIcon.isHidden = disclaimer.contains("去查看") + inviteView.isHidden = !showInviteRow + // 隐藏时高度归零,避免仍占 72pt 把卡片撑高 + inviteView.layoutChain + .topToBottomOfView(disclaimerLab, offset: showInviteRow ? 16 : 0) + .height(showInviteRow ? 72 : 0) + } + 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: " ") - return nav + BaseNavigationView(title: " ") }() - - // MARK: - 成功烟花Lottie - lazy var successLottieView: LottieAnimationView = { - let view = LottieAnimationView(name: "phone_search_fireworks") - view.loopMode = .loop - view.isUserInteractionEnabled = false - return view - }() - - // MARK: - 已解锁vip - lazy var normalView: UIView = { - let view = UIView() + + lazy var scrollView: UIScrollView = { + let view = UIScrollView() view.backgroundColor = .clear - - view.addSubview(normalLottieView) - normalLottieView.layoutChain - .top(20) - .width(270) - .heightToWidth(1) - .centerX() - - view.addSubview(phoneAreaLab) - phoneAreaLab.layoutChain - .topToBottomOfView(normalLottieView) - .centerX() - - view.addSubview(messageLab) - messageLab.layoutChain - .topToBottomOfView(phoneAreaLab, offset: 10) - .centerX() - - view.addSubview(tipsLab) - tipsLab.layoutChain - .topToBottomOfView(messageLab, offset: 15) - .edgesHorzontal(31) - - view.addSubview(inviteBtn) - inviteBtn.layoutChain - .topToBottomOfView(tipsLab, offset: 30) - .edgesHorzontal(68) - .height(50) - - view.addSubview(inviteView) - inviteView.layoutChain - .topToBottomOfView(tipsLab, offset: 25) + view.showsVerticalScrollIndicator = false + view.bounces = false + + let contentView = UIView() + contentView.backgroundColor = .clear + view.addSubview(contentView) + contentView.layoutChain.edges().widthToView(view) + + contentView.addSubview(headerView) + headerView.layoutChain.top(12).edgesHorzontal() + + contentView.addSubview(resultCard) + resultCard.layoutChain + .topToBottomOfView(headerView, offset: 20) .edgesHorzontal(15) - .height(80) - + + contentView.addSubview(actionBtn) + actionBtn.layoutChain + .topToBottomOfView(resultCard, offset: 24) + .edgesHorzontal(30) + .height(56) + .bottom(kSafeBottomMargin + 30) + return view }() - + + lazy var resultCard: UIView = { + let card = UIView() + card.backgroundColor = UIColor(hexStr: "#EFF9FF") + card.cornerRadius = 16 + + let locationRow = UIView() + card.addSubview(locationRow) + + let pinIcon = UIImageView(image: UIImage(named: "Home/member_location")) + pinIcon.contentMode = .scaleAspectFit + locationRow.addSubview(pinIcon) + locationRow.addSubview(phoneAreaLab) + + pinIcon.layoutChain.left().top().bottom().width(18).height(18) + phoneAreaLab.layoutChain + .leftToRightOfView(pinIcon, offset: 6) + .right() + .centerY(pinIcon) + locationRow.layoutChain.top(20).centerX() + + let pillContainer = UIView() + pillContainer.backgroundColor = .white + pillContainer.cornerRadius = 14 + card.addSubview(pillContainer) + pillContainer.addSubview(statusPillLab) + statusPillLab.layoutChain.edgesHorzontal(58).edgesVertical(13) + pillContainer.layoutChain + .topToBottomOfView(locationRow, offset: 11) + .centerX() + .height(48) + + card.addSubview(disclaimerIcon) + disclaimerIcon.layoutChain + .topToBottomOfView(pillContainer, offset: 14) + .left(24) + .width(12) + .height(12) + + card.addSubview(disclaimerLab) + disclaimerLab.layoutChain + .topToBottomOfView(pillContainer, offset: 11) + .leftToRightOfView(disclaimerIcon, offset: 3) + .right(23) + + card.addSubview(inviteView) + inviteView.layoutChain + .topToBottomOfView(disclaimerLab, offset: 0) + .edgesHorzontal(12) + .height(0) + .bottom(16) + + return card + }() + lazy var phoneAreaLab: UILabel = { let label = UILabel() - label.text = " " - label.font = .systemFont(ofSize: 20, weight: .semibold) + label.font = .systemFont(ofSize: 18, weight: .bold) + label.textColor = UIColor(hexStr: "#293445") + return label + }() + + lazy var statusPillLab: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 16, weight: .medium) label.textColor = UIColor(hexStr: "#16B3FF") + label.textAlignment = .center return label }() - - lazy var messageLab: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 16, weight: .bold) - label.textColor = UIColor(hexStr: "#333333") - return label + + private lazy var disclaimerIcon: UIImageView = { + let iv = UIImageView(image: UIImage(named: "SearchLocation/warning")) + iv.contentMode = .scaleAspectFit + return iv }() - - lazy var tipsLab: UILabel = { + + lazy var disclaimerLab: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 12, weight: .medium) + label.font = .systemFont(ofSize: 12, weight: .regular) label.textColor = UIColor(hexStr: "#999999") label.numberOfLines = 0 return label }() - - lazy var normalLottieView: LottieAnimationView = { - let view = LottieAnimationView() - view.loopMode = .loop - return view - }() - - lazy var inviteBtn: UIButton = { + + lazy var actionBtn: UIButton = { let btn = UIButton(type: .custom) - btn.setTitle("邀请Ta加入", 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 = 20 btn.isHidden = true return btn }() - + + var inviteBtn: UIButton { actionBtn } + lazy var inviteView: UIView = { let view = UIView() - view.backgroundColor = UIColor(hexStr: "#EFF9FF") - view.cornerRadius = 16 + view.backgroundColor = .white + view.cornerRadius = 12 view.isHidden = true - + view.addSubview(successBtn) - successBtn.layoutChain - .right(15) - .centerY() - .width(90) - .height(36) - + successBtn.layoutChain.right(12).centerY().width(90).height(36) + view.addSubview(successTipsLab) - successTipsLab.layoutChain - .topToCenterYOfView(view) - .left(15) - + successTipsLab.layoutChain.topToCenterYOfView(view).left(12) + view.addSubview(phoneLab) - phoneLab.layoutChain - .bottomToCenterYOfView(view) - .left(15) - + phoneLab.layoutChain.bottomToCenterYOfView(view).left(12) + return view }() - + lazy var phoneLab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 16, weight: .bold) label.textColor = UIColor(hexStr: "#333333") return label }() - + lazy var successTipsLab: UILabel = { let label = UILabel() - label.text = "邀请加入圈子后分享位置" label.font = .systemFont(ofSize: 10, weight: .medium) label.textColor = UIColor(hexStr: "#999999") return label }() - + lazy var successBtn: UIButton = { let btn = UIButton(type: .custom) - btn.setTitle("邀请Ta", for: .normal) btn.setTitleColor(.white, for: .normal) btn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) - btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + btn.backgroundColor = UIColor(hexStr: "#16B3FF") btn.cornerRadius = 18 return btn }() - - // MARK: - 未解锁vip + + // 兼容 VC 旧引用 + lazy var messageLab: UILabel = statusPillLab + lazy var tipsLab: UILabel = disclaimerLab + lazy var normalLottieView: LottieAnimationView = { + let view = LottieAnimationView() + view.isHidden = true + return view + }() + lazy var successLottieView: LottieAnimationView = { + let view = LottieAnimationView(name: "phone_search_fireworks") + view.isHidden = true + return view + }() + lazy var unlockVipView: UIView = { let view = UIView() - view.backgroundColor = .clear - view.clipsToBounds = true + view.backgroundColor = .white view.isHidden = true - - let bgImgView = UIImageView(image: UIImage(named: "SearchLocation/result_bg")) - bgImgView.contentMode = .scaleAspectFill - view.addSubview(bgImgView) - bgImgView.layoutChain - .edges(excludingEdge: .bottom) - .heightToWidth(457/357) - - view.addSubview(unlockVipLottieView) - unlockVipLottieView.layoutChain - .edgesHorzontal(60) - .heightToWidth(1) - .top(150) -// .bottomToView(bgImgView, offset: -15) - + + let heroPlaceholder = UIImageView() + heroPlaceholder.backgroundColor = UIColor(hexStr: "#F5F5F5") + heroPlaceholder.cornerRadius = 16 + view.addSubview(heroPlaceholder) + heroPlaceholder.layoutChain + .top(120) + .edgesHorzontal(40) + .height(200) + view.addSubview(unlockVipPhoneAreaLab) unlockVipPhoneAreaLab.layoutChain - .topToBottomOfView(unlockVipLottieView) + .topToBottomOfView(heroPlaceholder, offset: 16) .centerX() - - let locationView = UIView() - locationView.backgroundColor = .clear - view.addSubview(locationView) - locationView.layoutChain - .topToBottomOfView(unlockVipPhoneAreaLab, offset: 10) - .centerX() - - let locationTitleLab = UILabel() - locationTitleLab.text = "位置:" - locationTitleLab.font = .systemFont(ofSize: 16, weight: .bold) - locationTitleLab.textColor = UIColor(hexStr: "#16B3FF") - locationView.addSubview(locationTitleLab) - locationTitleLab.layoutChain - .left() - .centerY() - - let maskView = UIImageView(image: UIImage(named: "SearchLocation/mask")) - locationView.addSubview(maskView) - maskView.layoutChain - .leftToRightOfView(locationTitleLab) - .right() - .edgesVertical() - .width(144) - .height(30) - + let unlockVipBtn = UIButton() unlockVipBtn.setTitle("开通会员", for: .normal) unlockVipBtn.setTitleColor(.white, for: .normal) - unlockVipBtn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) - unlockVipBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + unlockVipBtn.titleLabel?.font = FontManager.boboBold(16) + unlockVipBtn.backgroundColor = UIColor(hexStr: "#16B3FF") unlockVipBtn.cornerRadius = 25 unlockVipBtn.rx.tap.subscribe(onNext: { _ in AppRouter.push(Route.vipRecharge) }).disposed(by: disposeBag) - view.addSubview(unlockVipBtn) unlockVipBtn.layoutChain .edgesHorzontal(15) .bottom(kSafeBottomMargin + 20) .height(50) - - // 最后出现地点 - let tipsDot3 = UIView() - tipsDot3.backgroundColor = UIColor(hexStr: "#16B3FF") - tipsDot3.cornerRadius = 2.5 - let tipsLab3 = UILabel() - tipsLab3.text = "最后出现地点" - tipsLab3.font = .systemFont(ofSize: 12, weight: .medium) - tipsLab3.textColor = UIColor(hexStr: "#333333") - view.addSubview(tipsLab3) - view.addSubview(tipsDot3) - tipsLab3.layoutChain - .bottomToTopOfView(unlockVipBtn, offset: -20) - .rightToCenterXOfView(view, offset: 30) - - tipsDot3.layoutChain - .rightToLeftOfView(tipsLab3, offset: -8) - .width(5) - .heightToWidth(1) - .centerY(tipsLab3) - - // "近24小时轨迹" - let tipsDot4 = UIView() - tipsDot4.backgroundColor = UIColor(hexStr: "#16B3FF") - tipsDot4.cornerRadius = 2.5 - let tipsLab4 = UILabel() - tipsLab4.text = "近24小时轨迹" - tipsLab4.font = .systemFont(ofSize: 12, weight: .medium) - tipsLab4.textColor = UIColor(hexStr: "#333333") - view.addSubview(tipsLab4) - view.addSubview(tipsDot4) - tipsDot4.layoutChain - .leftToCenterXOfView(view, offset: 30) - .width(5) - .heightToWidth(1) - .centerY(tipsLab4) - tipsLab4.layoutChain - .bottomToTopOfView(unlockVipBtn, offset: -20) - .leftToRightOfView(tipsDot4, offset: 8) - - // 是否在线 - let tipsDot1 = UIView() - tipsDot1.backgroundColor = UIColor(hexStr: "#16B3FF") - tipsDot1.cornerRadius = 2.5 - let tipsLab1 = UILabel() - tipsLab1.text = "是否在线" - tipsLab1.font = .systemFont(ofSize: 12, weight: .medium) - tipsLab1.textColor = UIColor(hexStr: "#333333") - view.addSubview(tipsLab1) - view.addSubview(tipsDot1) - tipsDot1.layoutChain - .leftToView(tipsDot3) - .width(5) - .heightToWidth(1) - .centerY(tipsLab1) - tipsLab1.layoutChain - .bottomToTopOfView(tipsLab3, offset: -10) - .leftToRightOfView(tipsDot1, offset: 8) - - // 实时位置变化 - let tipsDot2 = UIView() - tipsDot2.backgroundColor = UIColor(hexStr: "#16B3FF") - tipsDot2.cornerRadius = 2.5 - let tipsLab2 = UILabel() - tipsLab2.text = "实时位置变化" - tipsLab2.font = .systemFont(ofSize: 12, weight: .medium) - tipsLab2.textColor = UIColor(hexStr: "#333333") - view.addSubview(tipsLab2) - view.addSubview(tipsDot2) - tipsDot2.layoutChain - .leftToView(tipsDot4) - .width(5) - .heightToWidth(1) - .centerY(tipsLab2) - tipsLab2.layoutChain - .bottomToTopOfView(tipsLab4, offset: -10) - .leftToRightOfView(tipsDot2, offset: 8) - - // 再迈一步,你就能看到: + let unlockVipTitleLab = UILabel() unlockVipTitleLab.text = "再迈一步,你就能看到:" unlockVipTitleLab.font = .systemFont(ofSize: 14, weight: .medium) unlockVipTitleLab.textColor = UIColor(hexStr: "#16B3FF") view.addSubview(unlockVipTitleLab) unlockVipTitleLab.layoutChain - .bottomToTopOfView(tipsLab1, offset: -8) + .bottomToTopOfView(unlockVipBtn, offset: -60) .centerX() - + return view }() - + lazy var unlockVipLottieView: LottieAnimationView = { let view = LottieAnimationView(name: "phone_search_no_vip") - view.loopMode = .loop + view.isHidden = true return view }() - + lazy var unlockVipPhoneAreaLab: UILabel = { let label = UILabel() - label.text = " " label.font = .systemFont(ofSize: 20, weight: .semibold) label.textColor = UIColor(hexStr: "#16B3FF") return label }() - + override init(frame: CGRect) { super.init(frame: .zero) backgroundColor = .white setupUI() - setupRx() } required init?(coder aDecoder: NSCoder) { diff --git a/QuickLocation/Section/Home/SearchLocation/SearchLocationView.swift b/QuickLocation/Section/Home/SearchLocation/SearchLocationView.swift index 9a2a8c37..b09b53b3 100644 --- a/QuickLocation/Section/Home/SearchLocation/SearchLocationView.swift +++ b/QuickLocation/Section/Home/SearchLocation/SearchLocationView.swift @@ -2,8 +2,6 @@ // SearchLocationView.swift // QuickLocation // -// Created by 八条 on 2026/6/27. -// import UIKit import RxSwift @@ -21,6 +19,8 @@ class SearchLocationView: UIView { private var player: AVPlayer? private var timeObserver: Any? + let headerView = SearchLocationHeaderView() + private func setupRx() { phoneInputTF.rx.text .map { text -> String? in @@ -28,21 +28,14 @@ class SearchLocationView: UIView { return String(text.prefix(11)) }.bind(to: phoneInputTF.rx.text) .disposed(by: disposeBag) - - phoneInputTF.rx.text.orEmpty.map { phone -> Bool in - if phone.count == 11 { - return true - } else { - return false - } - } - .bind(to: searchBtn.rx.isEnabled) - .disposed(by: disposeBag) - + + phoneInputTF.rx.text.orEmpty.map { $0.count == 11 } + .bind(to: searchBtn.rx.isEnabled) + .disposed(by: disposeBag) + phoneInputTF.rx.controlEvent(.editingDidEndOnExit) .subscribe(onNext: { [weak self] in - guard let self = self else { return } - self.phoneInputTF.resignFirstResponder() + self?.phoneInputTF.resignFirstResponder() }) .disposed(by: disposeBag) } @@ -53,127 +46,28 @@ class SearchLocationView: UIView { addSubview(scrollView) addSubview(videoView) addSubview(searchProgressView) - - videoView.layoutChain.edges() + videoView.layoutChain.edges() searchProgressView.layoutChain .edgesHorzontal(15) .bottom(kSafeBottomMargin + 20) .height(221) - + navBgView.layoutChain .edges(excludingEdge: .bottom) - .heightToWidth(160/375) + .heightToWidth(160 / 375) navView.layoutChain .edges(excludingEdge: .bottom) .height(kNaviHeight) - + scrollView.layoutChain .topToBottomOfView(navView) .edges(excludingEdge: .top) } - // MARK: - Marquee - private func randomPhoneNumber() -> String { - // 所有合法前3位号段 - let prefixes = [ - "134","135","136","137","138","139","147","150","151","152","157","158","159","178","182","183","184","187","188","198", - "130","131","132","145","155","156","166","175","176","185","186","196", - "133","149","153","173","177","180","181","189","191","199" - ] - // 随机选一个号段 - let prefix = prefixes.randomElement()! - - // 随机生成后面8位数字 - var suffix = "" - for _ in 0..<8 { - suffix.append("\(Int.random(in: 0...9))") - } - - return prefix + suffix - } - - private func setupMarquee() { - let surnames = ["张", "李", "王", "陈", "刘", "杨", "赵", "黄", "周", "吴", - "林", "何", "马", "胡", "郑", "梁", "谢", "宋", "唐", "韩"] - - let container = UIView() - container.backgroundColor = .clear - marqueeScrollView.addSubview(container) - - var prevView: UIView? - for _ in 0..<10 { - let iconIndex = Int.random(in: 1...15) - let surname = surnames.randomElement() ?? "张" - let maskedName = "\(surname)**" - let phone = randomPhoneNumber() - let maskedPhone = phone.prefix(3) + "******" + phone.suffix(2) - - let itemView = UIView() - itemView.backgroundColor = UIColor(hexStr: "#EFF9FF") - itemView.cornerRadius = 6 - container.addSubview(itemView) - - let avatar = UIImageView(image: UIImage(named: "UserIcon/\(iconIndex)")) - avatar.contentMode = .scaleAspectFill - avatar.cornerRadius = 12 - avatar.clipsToBounds = true - itemView.addSubview(avatar) - - let label = UILabel() - label.font = .systemFont(ofSize: 13, weight: .medium) - let fullText = "\(maskedName) 定位到了 \(maskedPhone)" - let attr = NSMutableAttributedString(string: fullText) - attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), - range: NSRange(fullText.range(of: maskedName)!, in: fullText)) - if let phoneRange = fullText.range(of: maskedPhone) { - attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), - range: NSRange(phoneRange, in: fullText)) - } - label.attributedText = attr - itemView.addSubview(label) - - avatar.layoutChain - .left(10).centerY() - .width(24).height(24) - - label.layoutChain - .leftToRightOfView(avatar, offset: 6) - .centerY().right(10) - - itemView.layoutChain - .centerY() - .height(30) - - if let prev = prevView { - itemView.layoutChain.leftToRightOfView(prev, offset: 20) - } else { - itemView.layoutChain.left() - } - prevView = itemView - } - - if let last = prevView { - container.layoutChain - .edges().heightToView(marqueeScrollView) - .rightToView(last) - } - } - func startMarqueeAnimation() { - marqueeScrollView.layer.removeAllAnimations() - marqueeScrollView.contentOffset.x = 0 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self = self else { return } - let contentW = self.marqueeScrollView.contentSize.width - guard contentW > self.marqueeScrollView.bounds.width else { return } - let duration = contentW / 100 - - UIView.animate(withDuration: duration, delay: 0, options: [.curveLinear, .repeat]) { - self.marqueeScrollView.contentOffset.x = contentW - self.marqueeScrollView.bounds.width - } - } + headerView.startMarqueeAnimation() } override func willMove(toWindow newWindow: UIWindow?) { @@ -183,56 +77,41 @@ class SearchLocationView: UIView { } } - // MARK: - Views - lazy var navBgView: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "Common/navBar_bg_2") iv.contentMode = .scaleAspectFill + iv.isHidden = true return iv }() lazy var navView: BaseNavigationView = { - let nav = BaseNavigationView(title: "找人助手") - return nav + BaseNavigationView(title: " ") }() - + lazy var scrollView: UIScrollView = { let view = UIScrollView() view.backgroundColor = .clear view.showsVerticalScrollIndicator = false view.bounces = false - + let contentView = UIView() contentView.backgroundColor = .clear view.addSubview(contentView) contentView.layoutChain.edges().widthToView(view) - - let bgImgView = UIImageView() - bgImgView.image = UIImage(named: "SearchLocation/bg_1") - contentView.addSubview(bgImgView) - bgImgView.layoutChain - .top(15) - .edgesHorzontal(56.5) - .heightToWidth(626/524) - - contentView.addSubview(titleLab) - titleLab.layoutChain - .topToBottomOfView(bgImgView, offset: 0) - .centerX() - - contentView.addSubview(marqueeScrollView) - marqueeScrollView.layoutChain - .topToBottomOfView(titleLab, offset: 10) + + contentView.addSubview(headerView) + headerView.layoutChain + .top(12) .edgesHorzontal() - .height(30) - + .widthToView(contentView) + contentView.addSubview(searchInputView) searchInputView.layoutChain - .topToBottomOfView(marqueeScrollView, offset: 20) + .topToBottomOfView(headerView, offset: 20) .edgesHorzontal(15) .bottom(kSafeBottomMargin + 30) - + return view }() @@ -240,12 +119,12 @@ class SearchLocationView: UIView { let v = UIView() v.backgroundColor = .black v.isHidden = true - return v }() func playVideo(completion: (() -> Void)? = nil) { - guard let path = Bundle.main.path(forResource: "search_interlude", ofType: "mp4") ?? Bundle.main.path(forResource: "search_interlude", ofType: "mp4", inDirectory: "video") else { + guard let path = Bundle.main.path(forResource: "search_interlude", ofType: "mp4") + ?? Bundle.main.path(forResource: "search_interlude", ofType: "mp4", inDirectory: "video") else { print("[video] file not found in bundle: \(Bundle.main.resourcePath ?? "")") return } @@ -259,7 +138,6 @@ class SearchLocationView: UIView { playerLayer.videoGravity = .resizeAspectFill videoView.layer.addSublayer(playerLayer) - // 进度跟踪 timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.1, preferredTimescale: 600), queue: .main) { [weak self] time in guard let self = self, let duration = self.player?.currentItem?.duration.seconds, duration > 0 else { return } self.updateVideoProgress(Float(time.seconds / duration)) @@ -288,52 +166,24 @@ class SearchLocationView: UIView { } } - lazy var titleLab: UILabel = { - let label = UILabel() - label.text = "有 \(Int.random(in: 1000...10000)) 人正在使用此功能" - label.font = .systemFont(ofSize: 16, weight: .medium) - label.textColor = ThemeManager.shared.color.titleAuxColor - label.textAlignment = .center - return label - }() - - lazy var marqueeScrollView: UIScrollView = { - let sv = UIScrollView() - sv.backgroundColor = .clear - sv.showsHorizontalScrollIndicator = false - sv.isScrollEnabled = false - return sv - }() - lazy var searchInputView: UIView = { let view = UIView() - view.backgroundColor = UIColor(hexStr: "#EFF9FF") - view.cornerRadius = 10 - - let titleLab = UILabel() - titleLab.text = "TA在哪?输入号码就知道" - titleLab.font = .systemFont(ofSize: 16, weight: .semibold) - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - view.addSubview(titleLab) - titleLab.layoutChain - .top(18) - .left(15) + view.backgroundColor = UIColor(hexStr: "#EFFAFF") + view.cornerRadius = 40 let inputView = UIView() inputView.backgroundColor = .white - inputView.cornerRadius = 4 + inputView.cornerRadius = 14 view.addSubview(inputView) inputView.layoutChain - .topToBottomOfView(titleLab, offset: 20) + .top(20) .edgesHorzontal(15) - .height(40) + .height(44) let contactsBtn = UIButton() - contactsBtn.setTitle(" 通讯录导入", for: .normal) + contactsBtn.setTitle("通讯录导入", for: .normal) contactsBtn.setTitleColor(UIColor(hexStr: "#16B3FF"), for: .normal) contactsBtn.titleLabel?.font = .systemFont(ofSize: 12, weight: .regular) - contactsBtn.setImage(UIImage(named: "SearchLocation/contacts"), for: .normal) - contactsBtn.extendEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 15) contactsBtn.rx.tap.subscribe(onNext: { [weak self] in guard let self = self else { return } self.phoneInputTF.resignFirstResponder() @@ -352,11 +202,7 @@ class SearchLocationView: UIView { } }).disposed(by: disposeBag) inputView.addSubview(contactsBtn) - contactsBtn.layoutChain - .right(15) - .width(90) - .edgesVertical() - contactsBtn.sizeToFit() + contactsBtn.layoutChain.edgesVertical().width(60).right(12) inputView.addSubview(phoneInputTF) phoneInputTF.layoutChain @@ -364,21 +210,31 @@ class SearchLocationView: UIView { .left(15) .rightToLeftOfView(contactsBtn, offset: -8) + let warningIcon = UIImageView(image: UIImage(named: "SearchLocation/warning")) + warningIcon.contentMode = .scaleAspectFit + view.addSubview(warningIcon) + warningIcon.layoutChain + .topToBottomOfView(inputView, offset: 10) + .left(15) + .width(14) + .height(14) + let tipsLab = UILabel() tipsLab.text = "我们不会存储和泄露你导入的通讯录隐私" tipsLab.font = .systemFont(ofSize: 12, weight: .regular) - tipsLab.textColor = ThemeManager.shared.color.contentColor + tipsLab.textColor = UIColor(hexStr: "#999999") view.addSubview(tipsLab) tipsLab.layoutChain - .topToBottomOfView(inputView, offset: 6) + .leftToRightOfView(warningIcon, offset: 4) + .centerY(warningIcon) .right(15) view.addSubview(searchBtn) searchBtn.layoutChain - .topToBottomOfView(tipsLab, offset: 35) - .edgesHorzontal(52) + .topToBottomOfView(warningIcon, offset: 24) + .edgesHorzontal(20) .height(50) - .bottom(30) + .bottom(24) return view }() @@ -386,7 +242,7 @@ class SearchLocationView: UIView { lazy var phoneInputTF: UITextField = { let textField = UITextField() textField.font = .systemFont(ofSize: 14, weight: .medium) - textField.placeholder = "请输入要查找的号码" + textField.placeholder = "输入要查看的号码" textField.keyboardType = .numberPad textField.returnKeyType = .done return textField @@ -396,12 +252,12 @@ class SearchLocationView: UIView { let btn = UIButton(type: .custom) btn.setTitle("开始查找", for: .normal) btn.setTitleColor(.white, for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + btn.titleLabel?.font = FontManager.boboBold(16) btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) - btn.cornerRadius = 25 + btn.cornerRadius = 20 return btn }() - + lazy var searchProgressView: UIView = { let view = UIView() view.backgroundColor = .black.withAlphaComponent(0.8) @@ -409,17 +265,17 @@ class SearchLocationView: UIView { view.borderWidth = 0.5 view.borderColor = .white view.isHidden = true - + view.addSubview(searchPhoneLottieView) searchPhoneLottieView.layoutChain .top(30) .left(15) .width(120) .heightToWidth(1) - + let steps = ["分析用户号码", "正在找 CGI 蜂窝参数", "SS7 信息交流", "号码已获授权", "处理完成"] var prevStepView: UIView? - for (i, text) in steps.enumerated() { + for text in steps { let stepView = UIView() view.addSubview(stepView) @@ -434,14 +290,8 @@ class SearchLocationView: UIView { label.textColor = .white stepView.addSubview(label) - icon.layoutChain - .left().centerY() - .width(18).height(18) - - label.layoutChain - .leftToRightOfView(icon, offset: 8) - .centerY().right() - + icon.layoutChain.left().centerY().width(18).height(18) + label.layoutChain.leftToRightOfView(icon, offset: 8).centerY().right() stepView.layoutChain .leftToRightOfView(searchPhoneLottieView, offset: 10) .right(10) @@ -455,7 +305,6 @@ class SearchLocationView: UIView { prevStepView = stepView } - // 进度条 + 百分比 progressBar.layer.cornerRadius = 4 progressBar.clipsToBounds = true progressBar.trackTintColor = .white.withAlphaComponent(0.2) @@ -483,16 +332,13 @@ class SearchLocationView: UIView { private var progressIcons: [UIImageView] = [] private lazy var progressBar: UIProgressView = { - let p = UIProgressView() - return p + UIProgressView() }() private lazy var progressLab: UILabel = { - let l = UILabel() - return l + UILabel() }() - /// 根据视频进度更新步骤图标和进度条 (0~1) func updateVideoProgress(_ progress: Float) { let clamped = max(0, min(1, progress)) progressBar.progress = clamped @@ -517,7 +363,6 @@ class SearchLocationView: UIView { backgroundColor = .white setupUI() setupRx() - setupMarquee() } required init?(coder aDecoder: NSCoder) { diff --git a/QuickLocation/Section/Mine/About/AboutVC.swift b/QuickLocation/Section/Mine/About/AboutVC.swift new file mode 100644 index 00000000..e9455c35 --- /dev/null +++ b/QuickLocation/Section/Mine/About/AboutVC.swift @@ -0,0 +1,70 @@ +// +// AboutVC.swift +// QuickLocation +// + +import UIKit +import RxSwift +import RxCocoa + +final class AboutVC: BaseViewController { + + private var rootView: AboutView! + + override func loadView() { + rootView = AboutView(frame: UIScreen.main.bounds) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + bind() + } + + private func bind() { + rootView.navView.onBackTapped = { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + + rootView.feedbackBtn.rx.controlEvent(.touchUpInside) + .subscribe(onNext: { _ in + DLToast.show(text: "敬请期待") + }) + .disposed(by: disposeBag) + + rootView.rateBtn.rx.controlEvent(.touchUpInside) + .subscribe(onNext: { _ in + Self.openAppStoreReview() + }) + .disposed(by: disposeBag) + + rootView.agreementBtn.rx.controlEvent(.touchUpInside) + .subscribe(onNext: { _ in + AppRouter.push(Route.web, userInfo: ["url": URLManager.shared.userAgreementUrl]) + }) + .disposed(by: disposeBag) + + rootView.privacyBtn.rx.controlEvent(.touchUpInside) + .subscribe(onNext: { _ in + AppRouter.push(Route.web, userInfo: ["url": URLManager.shared.privacyPolicyUrl]) + }) + .disposed(by: disposeBag) + + rootView.kidsPrivacyBtn.rx.controlEvent(.touchUpInside) + .subscribe(onNext: { _ in + AppRouter.push(Route.web, userInfo: ["url": URLManager.shared.kidsPrivacyUrl]) + }) + .disposed(by: disposeBag) + } + + private static func openAppStoreReview() { + // Bundle 未配置 appId 时给提示;有则跳转写评价页 + let appId = AppSettings.shared.appId + guard !appId.isEmpty, + let url = URL(string: "itms-apps://itunes.apple.com/app/id\(appId)?action=write-review") else { + DLToast.show(text: "暂无法打开评价") + return + } + UIApplication.shared.open(url) + } +} diff --git a/QuickLocation/Section/Mine/About/AboutView.swift b/QuickLocation/Section/Mine/About/AboutView.swift new file mode 100644 index 00000000..459a5be6 --- /dev/null +++ b/QuickLocation/Section/Mine/About/AboutView.swift @@ -0,0 +1,124 @@ +// +// AboutView.swift +// QuickLocation +// + +import UIKit + +final class AboutView: UIView { + + lazy var navView: BaseNavigationView = { + BaseNavigationView(title: "关于我们") + }() + + lazy var navBgView: UIImageView = { + let iv = UIImageView(image: UIImage(named: "Common/navBar_bg_2")) + iv.contentMode = .scaleAspectFill + return iv + }() + + let logoImage = UIImageView(image: UIImage(named: "Mine/")) + let nameImage = UIImageView(image: UIImage(named: "Mine/about_logo")) + let versionLab = UILabel() + + let feedbackBtn = AboutMenuButton(title: "意见反馈") + let rateBtn = AboutMenuButton(title: "给个好评") + let agreementBtn = AboutMenuButton(title: "用户协议") + let privacyBtn = AboutMenuButton(title: "隐私政策") + let kidsPrivacyBtn = AboutMenuButton(title: "儿童隐私政策") + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#FAFAFA") + setupUI() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func setupUI() { + addSubview(navBgView) + addSubview(navView) + navBgView.layoutChain.edges(excludingEdge: .bottom).heightToWidth(160 / 375) + navView.layoutChain.top().edgesHorzontal().height(kNaviHeight) + + logoImage.contentMode = .scaleAspectFill + logoImage.backgroundColor = .lightGray + logoImage.cornerRadius = 30 + + addSubview(logoImage) + logoImage.layoutChain + .topToBottomOfView(navView, offset: 67) + .centerX() + .width(100) + .heightToWidth(1) + + addSubview(nameImage) + nameImage.layoutChain + .topToBottomOfView(logoImage, offset: 24) + .centerX() + .width(114) + .height(25) + + let card1 = makeCard() + addSubview(card1) + card1.layoutChain.topToBottomOfView(nameImage, offset: 40).edgesHorzontal(15) + stackInCard(card1, buttons: [feedbackBtn, rateBtn]) + + let card2 = makeCard() + addSubview(card2) + card2.layoutChain.topToBottomOfView(card1, offset: 12).edgesHorzontal(15) + stackInCard(card2, buttons: [agreementBtn, privacyBtn, kidsPrivacyBtn]) + + versionLab.text = kAppShortVersion + versionLab.font = .systemFont(ofSize: 12, weight: .medium) + versionLab.textColor = UIColor(hexStr: "#AAAAAA") + versionLab.textAlignment = .center + addSubview(versionLab) + versionLab.layoutChain.bottom(34).centerX() + } + + private func makeCard() -> UIView { + let v = UIView() + v.backgroundColor = .white + v.layer.cornerRadius = 16 + v.layer.shadowColor = UIColor(hexStr: "#0F2846", alpha: 0.08).cgColor + v.layer.shadowOffset = CGSize(width: 0, height: 2) + v.layer.shadowOpacity = 1 + v.layer.shadowRadius = 8 + return v + } + + private func stackInCard(_ card: UIView, buttons: [AboutMenuButton]) { + let stack = UIStackView(arrangedSubviews: buttons) + stack.axis = .vertical + stack.spacing = 0 + card.addSubview(stack) + stack.layoutChain.edges() + buttons.forEach { $0.layoutChain.height(52) } + for (i, btn) in buttons.enumerated() where i < buttons.count - 1 { + let line = UIView() + line.backgroundColor = UIColor(hexStr: "#F2F2F2") + btn.addSubview(line) + line.layoutChain.left(15).right(15).bottom(0).height(0.5) + } + } +} + +final class AboutMenuButton: UIControl { + private let titleLab = UILabel() + private let arrow = UIImageView(image: UIImage(named: "Mine/arrow_right")) + + init(title: String) { + super.init(frame: .zero) + titleLab.text = title + titleLab.font = .systemFont(ofSize: 15, weight: .medium) + titleLab.textColor = UIColor(hexStr: "#293445") + addSubview(titleLab) + titleLab.layoutChain.left(15).centerY() + arrow.contentMode = .scaleAspectFit + addSubview(arrow) + arrow.layoutChain.right(15).centerY().width(12).height(12) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} diff --git a/QuickLocation/Section/Mine/Account/AccountVC.swift b/QuickLocation/Section/Mine/Account/AccountVC.swift index f3e92503..e4301aaf 100644 --- a/QuickLocation/Section/Mine/Account/AccountVC.swift +++ b/QuickLocation/Section/Mine/Account/AccountVC.swift @@ -53,9 +53,7 @@ class AccountVC: BaseViewController { }).disposed(by: disposeBag) rootView.cancelledBtn.rx.tap.subscribe(onNext: { _ in - CancellationPopView.show { - self.deleteAccount() - } + AppRouter.push(Route.cancelAccount) }).disposed(by: disposeBag) let tap = UITapGestureRecognizer(target: self, action: #selector(handleClearCacheTap)) diff --git a/QuickLocation/Section/Mine/Account/AccountView.swift b/QuickLocation/Section/Mine/Account/AccountView.swift index 53ef00a4..ac7557a8 100644 --- a/QuickLocation/Section/Mine/Account/AccountView.swift +++ b/QuickLocation/Section/Mine/Account/AccountView.swift @@ -290,10 +290,10 @@ class AccountView: UIView { lazy var logoutBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("退出登录", for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + btn.titleLabel?.font = FontManager.boboBold(18) btn.setTitleColor(.white, for: .normal) btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) - btn.cornerRadius = 25 + btn.cornerRadius = 16 return btn }() diff --git a/QuickLocation/Section/Mine/CancelAccount/CancelAccountVC.swift b/QuickLocation/Section/Mine/CancelAccount/CancelAccountVC.swift new file mode 100644 index 00000000..ac47ad3f --- /dev/null +++ b/QuickLocation/Section/Mine/CancelAccount/CancelAccountVC.swift @@ -0,0 +1,105 @@ +// +// CancelAccountVC.swift +// QuickLocation +// + +import UIKit +import RxSwift +import RxCocoa +#if !targetEnvironment(simulator) +import GeYanSdk +#endif + +final class CancelAccountVC: BaseViewController { + + private var rootView: CancelAccountView! + private let requiredPhrase = "我自愿注销本账号" + + override func loadView() { + rootView = CancelAccountView(frame: UIScreen.main.bounds) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + bind() + } + + private func bind() { + rootView.navView.onBackTapped = { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + + rootView.checkItems.forEach { item in + item.checkBtn.rx.tap + .subscribe(onNext: { [weak self, weak item] in + item?.checkBtn.isSelected.toggle() + self?.refreshSaveEnabled() + }) + .disposed(by: disposeBag) + } + + rootView.confirmInput.rx.text.orEmpty + .subscribe(onNext: { [weak self] _ in + self?.refreshSaveEnabled() + }) + .disposed(by: disposeBag) + + rootView.saveBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.submit() + }) + .disposed(by: disposeBag) + } + + private func refreshSaveEnabled() { + let allChecked = rootView.checkItems.allSatisfy(\.isChecked) + let phraseOK = rootView.confirmInput.text == requiredPhrase + rootView.setSaveEnabled(allChecked && phraseOK) + } + + private func submit() { + guard rootView.checkItems.allSatisfy(\.isChecked), + rootView.confirmInput.text == requiredPhrase else { return } + DLToast.showLoading() + UserService.deleteAccount().subscribe(onNext: { [weak self] _ in + DLToast.dismiss() + self?.showSuccessToast { + NotificationCenter.default.post(name: .invalidatePopupQueue, object: nil) + #if !targetEnvironment(simulator) + GeYanSdk.deletePreResultCache() + #endif + AppContextManager.shared.deleteAccount() + GroupIMService.shared.logout() + AppDelegate.shared.showMainViewController() + } + }, onError: { _ in }).disposed(by: disposeBag) + } + + private func showSuccessToast(completion: @escaping () -> Void) { + let toast = UIView() + toast.backgroundColor = UIColor.black.withAlphaComponent(0.75) + toast.layer.cornerRadius = 12 + view.addSubview(toast) + toast.layoutChain.center().width(120).height(120) + + let icon = UIImageView(image: UIImage(systemName: "checkmark.circle")) + icon.tintColor = .white + icon.contentMode = .scaleAspectFit + toast.addSubview(icon) + icon.layoutChain.top(28).centerX().width(36).height(36) + + let lab = UILabel() + lab.text = "注销成功" + lab.textColor = .white + lab.font = .systemFont(ofSize: 14, weight: .medium) + lab.textAlignment = .center + toast.addSubview(lab) + lab.layoutChain.topToBottomOfView(icon, offset: 12).edgesHorzontal(8) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + toast.removeFromSuperview() + completion() + } + } +} diff --git a/QuickLocation/Section/Mine/CancelAccount/CancelAccountView.swift b/QuickLocation/Section/Mine/CancelAccount/CancelAccountView.swift new file mode 100644 index 00000000..27c8c305 --- /dev/null +++ b/QuickLocation/Section/Mine/CancelAccount/CancelAccountView.swift @@ -0,0 +1,165 @@ +// +// CancelAccountView.swift +// QuickLocation +// + +import UIKit + +final class CancelAccountView: UIView { + + lazy var navView = BaseNavigationView(title: "注销账号") + + let scrollView = UIScrollView() + let contentView = UIView() + let warningIcon = UIImageView(image: UIImage(named: "Mine/cancel_warning")) + let introLab = UILabel() + let card = UIView() + let confirmInput = UITextField() + let saveBtn = UIButton(type: .custom) + + private(set) var checkItems: [CancelCheckItemView] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#F9F9F9") + setupUI() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func setupUI() { + addSubview(navView) + navView.layoutChain.top().edgesHorzontal().height(kNaviHeight) + + addSubview(saveBtn) + saveBtn.setTitle("保存", for: .normal) + saveBtn.titleLabel?.font = FontManager.boboBold(18) + saveBtn.setTitleColor(.white, for: .normal) + saveBtn.backgroundColor = UIColor(hexStr: "#FF9B9B") + saveBtn.layer.cornerRadius = 20 + saveBtn.isEnabled = false + saveBtn.alpha = 0.5 + saveBtn.layoutChain.left(20).right(20).bottom(34).height(56) + + addSubview(scrollView) + scrollView.keyboardDismissMode = .onDrag + scrollView.backgroundColor = .clear + scrollView.layoutChain + .topToBottomOfView(navView) + .edgesHorzontal() + .bottomToTopOfView(saveBtn, offset: -12) + scrollView.addSubview(contentView) + contentView.backgroundColor = UIColor(hexStr: "#F9F9F9") + contentView.layoutChain.edges().widthToView(scrollView) + + warningIcon.contentMode = .scaleAspectFit + contentView.addSubview(warningIcon) + warningIcon.layoutChain.top(24).centerX().width(68).height(68) + + introLab.text = "为保证你的账号安全,在你提交的注销申请生效前,需要同时满足以下条件:" + introLab.font = .systemFont(ofSize: 16, weight: .medium) + introLab.textColor = UIColor(hexStr: "#3D3D3D") + introLab.numberOfLines = 0 + contentView.addSubview(introLab) + introLab.layoutChain.topToBottomOfView(warningIcon, offset: 16).edgesHorzontal(20) + + card.backgroundColor = .white + card.layer.cornerRadius = 24 + contentView.addSubview(card) + card.layoutChain + .topToBottomOfView(introLab, offset: 16) + .edgesHorzontal(15) + .bottom(20) + + let specs: [(String, String)] = [ + ("1. 放弃当前的VIP权益", "当前充值的VIP我们不会退款,请解除你的自动续费(如果有),同意请打勾"), + ("2. 当前账号没有违规及其他的法律纠纷", "同意请打勾"), + ("3. 确认是账号本人进行注销操作,自愿承担后果", "同意请打勾"), + ("4. 账号注销后账号不可登录,不可恢复", "同意请打勾") + ] + var previous: UIView? + for spec in specs { + let item = CancelCheckItemView(title: spec.0, subtitle: spec.1) + checkItems.append(item) + card.addSubview(item) + if let previous { + item.layoutChain.topToBottomOfView(previous).edgesHorzontal(14) + } else { + item.layoutChain.top(14).edgesHorzontal(14) + } + previous = item + } + + let tip5 = UILabel() + tip5.text = "5. 请在输入框中输入以下文字" + tip5.font = .systemFont(ofSize: 16, weight: .medium) + tip5.textColor = UIColor(hexStr: "#293445") + card.addSubview(tip5) + tip5.layoutChain.topToBottomOfView(previous!, offset: 12).left(14).right(14) + + let phrase = UILabel() + phrase.text = "我自愿注销本账号" + phrase.font = .systemFont(ofSize: 16, weight: .medium) + phrase.textColor = UIColor(hexStr: "#F64545") + card.addSubview(phrase) + phrase.layoutChain.topToBottomOfView(tip5, offset: 8).leftToView(tip5, offset: 16) + + confirmInput.backgroundColor = UIColor(hexStr: "#F5F5F5") + confirmInput.layer.cornerRadius = 8 + confirmInput.borderWidth = 1 + confirmInput.borderColor = UIColor(hexStr: "#DEDEDE") + confirmInput.font = .systemFont(ofSize: 14, weight: .medium) + confirmInput.textColor = UIColor(hexStr: "#293445") + confirmInput.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + confirmInput.leftViewMode = .always + confirmInput.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + confirmInput.rightViewMode = .always + card.addSubview(confirmInput) + confirmInput.layoutChain + .topToBottomOfView(phrase, offset: 10) + .left(14) + .right(14) + .height(40) + .bottom(16) + } + + func setSaveEnabled(_ enabled: Bool) { + saveBtn.isEnabled = enabled + saveBtn.alpha = enabled ? 1 : 0.5 + saveBtn.backgroundColor = UIColor(hexStr: enabled ? "#FF5252" : "#FF9B9B") + } +} + +final class CancelCheckItemView: UIView { + let checkBtn = UIButton(type: .custom) + private let titleLab = UILabel() + private let subLab = UILabel() + + var isChecked: Bool { checkBtn.isSelected } + + init(title: String, subtitle: String) { + super.init(frame: .zero) + titleLab.text = title + titleLab.font = .systemFont(ofSize: 15, weight: .medium) + titleLab.textColor = UIColor(hexStr: "#293445") + titleLab.numberOfLines = 0 + addSubview(titleLab) + + subLab.text = subtitle + subLab.font = .systemFont(ofSize: 13, weight: .medium) + subLab.textColor = UIColor(hexStr: "#767676") + subLab.numberOfLines = 0 + addSubview(subLab) + + checkBtn.setBackgroundImage(UIImage(named: "Common/checkbox"), for: .normal) + checkBtn.setBackgroundImage(UIImage(named: "Common/checkbox_on"), for: .selected) + checkBtn.extendEdgeInsets = UIEdgeInsets(top: 0, left: 20, bottom: 20, right: 20) + addSubview(checkBtn) + + titleLab.layoutChain.top(10).left(0).rightToLeftOfView(checkBtn, offset: 8) + subLab.layoutChain.topToBottomOfView(titleLab, offset: 4).left(0).rightToLeftOfView(checkBtn, offset: 12).bottom(10) + checkBtn.layoutChain.right(0).centerY(titleLab).width(24).height(24) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} diff --git a/QuickLocation/Section/Mine/MinePhotoWallView.swift b/QuickLocation/Section/Mine/MinePhotoWallView.swift index 17829f07..3e23aab1 100644 --- a/QuickLocation/Section/Mine/MinePhotoWallView.swift +++ b/QuickLocation/Section/Mine/MinePhotoWallView.swift @@ -12,7 +12,8 @@ final class MinePhotoWallView: UIView { var onCenteredMember: ((GroupMemberModel) -> Void)? var onCopyID: ((String) -> Void)? - var onTapSelfAvatar: (() -> Void)? + /// 点击照片墙上某成员(含自己)→ 进入成员信息 + var onTapMember: ((GroupMemberModel) -> Void)? private var members: [GroupMemberModel] = [] /// 与 members 对齐的夹子颜色下标(相邻不同色) @@ -463,15 +464,13 @@ extension MinePhotoWallView: UICollectionViewDataSource, UICollectionViewDelegat func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let model = members[indexPath.item] - if model.user_id == AppContextManager.shared.userId { - onTapSelfAvatar?() - } centeredIndex = indexPath.item let before = collectionView.contentOffset scrollToIndex(indexPath.item, animated: true) applyArcAndFocus() updateInfo(model) onCenteredMember?(model) + onTapMember?(model) DispatchQueue.main.async { [weak self] in guard let self = self else { return } if abs(self.collectionView.contentOffset.x - before.x) < 0.5, !self.isDragging { diff --git a/QuickLocation/Section/Mine/MineViewController.swift b/QuickLocation/Section/Mine/MineViewController.swift index 504e38a3..eec0c366 100644 --- a/QuickLocation/Section/Mine/MineViewController.swift +++ b/QuickLocation/Section/Mine/MineViewController.swift @@ -9,6 +9,7 @@ import UIKit import RxSwift import RxCocoa import RxDataSources +import ObjectMapper final class MineViewController: BaseViewController { @@ -23,6 +24,8 @@ final class MineViewController: BaseViewController { private let viewModel = MineViewModel() private var groupKey: String = "" + private var groupModel: GroupModel? + private var photoWallMembers: [GroupMemberModel] = [] override func loadView() { rootView = MineView(frame: UIScreen.main.bounds) @@ -64,8 +67,8 @@ final class MineViewController: BaseViewController { DLToast.showSuccess(text: "已复制") } - rootView.photoWall.onTapSelfAvatar = { - AppRouter.push(Route.myProfile) + rootView.photoWall.onTapMember = { [weak self] member in + self?.openMemberInfo(selectedUserId: member.user_id) } rootView.onQuickTap = { [weak self] title in @@ -94,8 +97,10 @@ final class MineViewController: BaseViewController { private func requestGroupMembers() { GroupService.groupInfo().subscribe(onNext: { [weak self] response in guard let self = self, let model = response.model else { return } + self.groupModel = model self.groupKey = model.default_group_key let sorted = self.viewModel.sortedMembers(model.select_group_employee, groupKey: model.default_group_key) + self.photoWallMembers = sorted // 圈主 id:group_key 含 userId let ownerId = sorted.first(where: { model.default_group_key.contains($0.user_id) })?.user_id ?? "" self.rootView.photoWall.reload( @@ -105,4 +110,17 @@ final class MineViewController: BaseViewController { ) }).disposed(by: disposeBag) } + + private func openMemberInfo(selectedUserId: String) { + let members = photoWallMembers.map { $0.toJSON() } + guard !members.isEmpty else { return } + var userInfo: [String: Any] = [ + "members": members, + "userId": selectedUserId + ] + if let group = groupModel { + userInfo["groupJson"] = group.toJSON() + } + AppRouter.push(Route.memberInfo, userInfo: userInfo) + } } diff --git a/QuickLocation/Section/Mine/MineViewModel.swift b/QuickLocation/Section/Mine/MineViewModel.swift index 98c4ec44..bfd58954 100644 --- a/QuickLocation/Section/Mine/MineViewModel.swift +++ b/QuickLocation/Section/Mine/MineViewModel.swift @@ -59,8 +59,10 @@ class MineViewModel { requestWechatService() case "权限检测": AppRouter.push(Route.checkPermission) - case "数据安全", "状态设置", "关于我们": + case "数据安全", "状态设置": DLToast.show(text: "敬请期待") + case "关于我们": + AppRouter.push(Route.about) default: break } diff --git a/QuickLocation/Section/VipRecharge/VipRechargeView.swift b/QuickLocation/Section/VipRecharge/VipRechargeView.swift index c88f2b78..25bd206e 100644 --- a/QuickLocation/Section/VipRecharge/VipRechargeView.swift +++ b/QuickLocation/Section/VipRecharge/VipRechargeView.swift @@ -15,7 +15,11 @@ class VipRechargeView: UIView { private func setupRx() { navView.onBackTapped = { VipWaivePopView.show() } - + + backBtn.rx.tap.subscribe { _ in + VipWaivePopView.show() + }.disposed(by: disposeBag) + agreementLab.rx.tapGesture.subscribe { _ in AppRouter.push(Route.web, userInfo: ["url": URLManager.shared.userAgreementUrl]) }.disposed(by: disposeBag) @@ -45,11 +49,19 @@ class VipRechargeView: UIView { bottomView.addSubview(payBtnView) bottomView.addSubview(payPriceView) + addSubview(backBtn) addSubview(navView) + backBtn.layoutChain + .top(kStatusBarHeight + 5) + .left(15) + .width(36) + .height(36) + navView.layoutChain .edges(excludingEdge: .bottom) .height(kNaviHeight) + navView.backgroundColor = .white navView.alpha = 0 bottomView.layoutChain @@ -131,6 +143,13 @@ class VipRechargeView: UIView { lazy var navView = BaseNavigationView(title: "升级会员") + lazy var backBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "Common/back"), for: .normal) + btn.extendEdgeInsets = UIEdgeInsets(top: 54, left: 15, bottom: 100, right: 100) + return btn + }() + lazy var scrollView: UIScrollView = { let view = UIScrollView() view.backgroundColor = UIColor(hexStr: "#F5FBFB") @@ -716,7 +735,7 @@ class VipRechargeView: UIView { extension VipRechargeView: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { let maxY = scrollView.contentOffset.y - let alpha = maxY / kNaviHeight + let alpha = maxY / (kNaviHeight / 2) navView.alpha = alpha < 0.0 ? 0.0 : alpha } }