parent
25aac678ac
commit
fb4a63d10b
|
|
@ -103,6 +103,7 @@
|
||||||
305A76E22FCA8C7000227D26 /* GroupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76252FCA8C7000227D26 /* GroupViewModel.swift */; };
|
305A76E22FCA8C7000227D26 /* GroupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76252FCA8C7000227D26 /* GroupViewModel.swift */; };
|
||||||
305A76E52FCA8C7000227D26 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76292FCA8C7000227D26 /* HomeViewController.swift */; };
|
305A76E52FCA8C7000227D26 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76292FCA8C7000227D26 /* HomeViewController.swift */; };
|
||||||
305A76E62FCA8C7000227D26 /* LaunchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A762B2FCA8C7000227D26 /* LaunchViewController.swift */; };
|
305A76E62FCA8C7000227D26 /* LaunchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A762B2FCA8C7000227D26 /* LaunchViewController.swift */; };
|
||||||
|
55B2A1013022F0A000784901 /* LaunchPrivacyConsentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B2A1003022F0A000784900 /* LaunchPrivacyConsentView.swift */; };
|
||||||
305A76E72FCA8C7000227D26 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A762D2FCA8C7000227D26 /* LoginView.swift */; };
|
305A76E72FCA8C7000227D26 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A762D2FCA8C7000227D26 /* LoginView.swift */; };
|
||||||
305A76E82FCA8C7000227D26 /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A762E2FCA8C7000227D26 /* LoginViewController.swift */; };
|
305A76E82FCA8C7000227D26 /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A762E2FCA8C7000227D26 /* LoginViewController.swift */; };
|
||||||
305A76E92FCA8C7000227D26 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A762F2FCA8C7000227D26 /* LoginViewModel.swift */; };
|
305A76E92FCA8C7000227D26 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A762F2FCA8C7000227D26 /* LoginViewModel.swift */; };
|
||||||
|
|
@ -288,15 +289,10 @@
|
||||||
55B217A33022F0B100784706 /* UIDevice+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217A33022F0B100784707 /* UIDevice+Extension.swift */; };
|
55B217A33022F0B100784706 /* UIDevice+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217A33022F0B100784707 /* UIDevice+Extension.swift */; };
|
||||||
55B217B13022F0A000784711 /* GroupItineraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217B03022F0A000784710 /* GroupItineraryView.swift */; };
|
55B217B13022F0A000784711 /* GroupItineraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217B03022F0A000784710 /* GroupItineraryView.swift */; };
|
||||||
55B217C13022F0A000784721 /* MinePhotoWallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C03022F0A000784720 /* MinePhotoWallView.swift */; };
|
55B217C13022F0A000784721 /* MinePhotoWallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C03022F0A000784720 /* MinePhotoWallView.swift */; };
|
||||||
55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */; };
|
|
||||||
55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C43022F0A000784724 /* MemberAppUsageView.swift */; };
|
55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C43022F0A000784724 /* MemberAppUsageView.swift */; };
|
||||||
55B217D13022F0A000784727 /* UnlockCountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D03022F0A000784726 /* UnlockCountManager.swift */; };
|
|
||||||
55B217D23023010000784731 /* TodayTrackDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D13023010000784730 /* TodayTrackDetailVC.swift */; };
|
55B217D23023010000784731 /* TodayTrackDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D13023010000784730 /* TodayTrackDetailVC.swift */; };
|
||||||
55B217D43023010000784733 /* TodayTrackDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D33023010000784732 /* TodayTrackDetailView.swift */; };
|
55B217D43023010000784733 /* TodayTrackDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D33023010000784732 /* TodayTrackDetailView.swift */; };
|
||||||
55B217D63023010000784735 /* TodayTrackDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */; };
|
55B217D63023010000784735 /* TodayTrackDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */; };
|
||||||
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 */; };
|
55B218A13024A00100784702 /* AboutVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784701 /* AboutVC.swift */; };
|
||||||
55B218A13024A00100784704 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784703 /* AboutView.swift */; };
|
55B218A13024A00100784704 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784703 /* AboutView.swift */; };
|
||||||
55B218A13024A00100784706 /* CancelAccountVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784705 /* CancelAccountVC.swift */; };
|
55B218A13024A00100784706 /* CancelAccountVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784705 /* CancelAccountVC.swift */; };
|
||||||
|
|
@ -530,6 +526,7 @@
|
||||||
305A76252FCA8C7000227D26 /* GroupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupViewModel.swift; sourceTree = "<group>"; };
|
305A76252FCA8C7000227D26 /* GroupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupViewModel.swift; sourceTree = "<group>"; };
|
||||||
305A76292FCA8C7000227D26 /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = "<group>"; };
|
305A76292FCA8C7000227D26 /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = "<group>"; };
|
||||||
305A762B2FCA8C7000227D26 /* LaunchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchViewController.swift; sourceTree = "<group>"; };
|
305A762B2FCA8C7000227D26 /* LaunchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchViewController.swift; sourceTree = "<group>"; };
|
||||||
|
55B2A1003022F0A000784900 /* LaunchPrivacyConsentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchPrivacyConsentView.swift; sourceTree = "<group>"; };
|
||||||
305A762D2FCA8C7000227D26 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
305A762D2FCA8C7000227D26 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||||
305A762E2FCA8C7000227D26 /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = "<group>"; };
|
305A762E2FCA8C7000227D26 /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = "<group>"; };
|
||||||
305A762F2FCA8C7000227D26 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = "<group>"; };
|
305A762F2FCA8C7000227D26 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -724,15 +721,10 @@
|
||||||
55B217A33022F0B100784707 /* UIDevice+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extension.swift"; sourceTree = "<group>"; };
|
55B217A33022F0B100784707 /* UIDevice+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extension.swift"; sourceTree = "<group>"; };
|
||||||
55B217B03022F0A000784710 /* GroupItineraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupItineraryView.swift; sourceTree = "<group>"; };
|
55B217B03022F0A000784710 /* GroupItineraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupItineraryView.swift; sourceTree = "<group>"; };
|
||||||
55B217C03022F0A000784720 /* MinePhotoWallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinePhotoWallView.swift; sourceTree = "<group>"; };
|
55B217C03022F0A000784720 /* MinePhotoWallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinePhotoWallView.swift; sourceTree = "<group>"; };
|
||||||
55B217C23022F0A000784722 /* MemberPhoneReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberPhoneReportView.swift; sourceTree = "<group>"; };
|
|
||||||
55B217C43022F0A000784724 /* MemberAppUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAppUsageView.swift; sourceTree = "<group>"; };
|
55B217C43022F0A000784724 /* MemberAppUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAppUsageView.swift; sourceTree = "<group>"; };
|
||||||
55B217D03022F0A000784726 /* UnlockCountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockCountManager.swift; sourceTree = "<group>"; };
|
|
||||||
55B217D13023010000784730 /* TodayTrackDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailVC.swift; sourceTree = "<group>"; };
|
55B217D13023010000784730 /* TodayTrackDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailVC.swift; sourceTree = "<group>"; };
|
||||||
55B217D33023010000784732 /* TodayTrackDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailView.swift; sourceTree = "<group>"; };
|
55B217D33023010000784732 /* TodayTrackDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailView.swift; sourceTree = "<group>"; };
|
||||||
55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailViewModel.swift; sourceTree = "<group>"; };
|
55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailViewModel.swift; sourceTree = "<group>"; };
|
||||||
55B217D73023010000784736 /* PhoneReportDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailVC.swift; sourceTree = "<group>"; };
|
|
||||||
55B217D93023010000784738 /* PhoneReportDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailView.swift; sourceTree = "<group>"; };
|
|
||||||
55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailViewModel.swift; sourceTree = "<group>"; };
|
|
||||||
55B218A13024A00100784701 /* AboutVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutVC.swift; sourceTree = "<group>"; };
|
55B218A13024A00100784701 /* AboutVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutVC.swift; sourceTree = "<group>"; };
|
||||||
55B218A13024A00100784703 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
|
55B218A13024A00100784703 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
|
||||||
55B218A13024A00100784705 /* CancelAccountVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelAccountVC.swift; sourceTree = "<group>"; };
|
55B218A13024A00100784705 /* CancelAccountVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelAccountVC.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -821,29 +813,21 @@
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
30CCDF8E2FE3E63B00F5214A /* sound */ = {
|
30CCDF8E2FE3E63B00F5214A /* sound */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = sound;
|
path = sound;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
30CCDF902FE3E63B00F5214A /* video */ = {
|
30CCDF902FE3E63B00F5214A /* video */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = video;
|
path = video;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
30CCE01E2FE3E64700F5214A /* lotties */ = {
|
30CCE01E2FE3E64700F5214A /* lotties */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = lotties;
|
path = lotties;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
30D87CEF2FDFF52100E958FD /* TTGTagCollectionView */ = {
|
30D87CEF2FDFF52100E958FD /* TTGTagCollectionView */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = TTGTagCollectionView;
|
path = TTGTagCollectionView;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
|
@ -1193,7 +1177,6 @@
|
||||||
305A75262FCA8C7000227D26 /* Money */,
|
305A75262FCA8C7000227D26 /* Money */,
|
||||||
305A75282FCA8C7000227D26 /* Notification */,
|
305A75282FCA8C7000227D26 /* Notification */,
|
||||||
305A752A2FCA8C7000227D26 /* Theme */,
|
305A752A2FCA8C7000227D26 /* Theme */,
|
||||||
55B217D23022F0A000784728 /* Unlock */,
|
|
||||||
305A752C2FCA8C7000227D26 /* URL */,
|
305A752C2FCA8C7000227D26 /* URL */,
|
||||||
30D87CD52FDF9F1900E958FD /* MQTT */,
|
30D87CD52FDF9F1900E958FD /* MQTT */,
|
||||||
2F518AB6587CC227CC515D8C /* AppRestrict */,
|
2F518AB6587CC227CC515D8C /* AppRestrict */,
|
||||||
|
|
@ -1297,10 +1280,8 @@
|
||||||
55B250013050000100784901 /* LockedAppPopView.swift */,
|
55B250013050000100784901 /* LockedAppPopView.swift */,
|
||||||
55B219B1302A000100784760 /* ReceiveMessagePopView.swift */,
|
55B219B1302A000100784760 /* ReceiveMessagePopView.swift */,
|
||||||
55B219B3302A000100784762 /* ReceiveMessageReportPopView.swift */,
|
55B219B3302A000100784762 /* ReceiveMessageReportPopView.swift */,
|
||||||
55B217C23022F0A000784722 /* MemberPhoneReportView.swift */,
|
|
||||||
55B217C43022F0A000784724 /* MemberAppUsageView.swift */,
|
55B217C43022F0A000784724 /* MemberAppUsageView.swift */,
|
||||||
55B217DD302301000078473C /* TodayTrackDetail */,
|
55B217DD302301000078473C /* TodayTrackDetail */,
|
||||||
55B217DE302301000078473D /* PhoneReportDetail */,
|
|
||||||
55B240063040000100784906 /* AppUsageReport */,
|
55B240063040000100784906 /* AppUsageReport */,
|
||||||
30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */,
|
30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */,
|
||||||
30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */,
|
30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */,
|
||||||
|
|
@ -1318,6 +1299,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
305A762B2FCA8C7000227D26 /* LaunchViewController.swift */,
|
305A762B2FCA8C7000227D26 /* LaunchViewController.swift */,
|
||||||
|
55B2A1003022F0A000784900 /* LaunchPrivacyConsentView.swift */,
|
||||||
);
|
);
|
||||||
path = Launch;
|
path = Launch;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -1967,14 +1949,6 @@
|
||||||
path = Font;
|
path = Font;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
55B217D23022F0A000784728 /* Unlock */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
55B217D03022F0A000784726 /* UnlockCountManager.swift */,
|
|
||||||
);
|
|
||||||
path = Unlock;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
55B217DD302301000078473C /* TodayTrackDetail */ = {
|
55B217DD302301000078473C /* TodayTrackDetail */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -1985,16 +1959,6 @@
|
||||||
path = TodayTrackDetail;
|
path = TodayTrackDetail;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
55B217DE302301000078473D /* PhoneReportDetail */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
55B217D73023010000784736 /* PhoneReportDetailVC.swift */,
|
|
||||||
55B217D93023010000784738 /* PhoneReportDetailView.swift */,
|
|
||||||
55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */,
|
|
||||||
);
|
|
||||||
path = PhoneReportDetail;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
55B218A13024A00100784710 /* About */ = {
|
55B218A13024A00100784710 /* About */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -2129,7 +2093,6 @@
|
||||||
51E818049FA5A8557C434E37 /* ImageCropView.swift */,
|
51E818049FA5A8557C434E37 /* ImageCropView.swift */,
|
||||||
C39ADD47D3C81F01F6289AC4 /* ImageCropVC.swift */,
|
C39ADD47D3C81F01F6289AC4 /* ImageCropVC.swift */,
|
||||||
);
|
);
|
||||||
name = ImageCrop;
|
|
||||||
path = ImageCrop;
|
path = ImageCrop;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
|
@ -2509,14 +2472,10 @@
|
||||||
305A76C22FCA8C7000227D26 /* BaseViewModel.swift in Sources */,
|
305A76C22FCA8C7000227D26 /* BaseViewModel.swift in Sources */,
|
||||||
55B2179330218CE100784774 /* GroupMemberView2.swift in Sources */,
|
55B2179330218CE100784774 /* GroupMemberView2.swift in Sources */,
|
||||||
55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */,
|
55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */,
|
||||||
55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */,
|
|
||||||
55C1A0213034102000786021 /* PrivilegeDeniedOverlay.swift in Sources */,
|
55C1A0213034102000786021 /* PrivilegeDeniedOverlay.swift in Sources */,
|
||||||
55B217D23023010000784731 /* TodayTrackDetailVC.swift in Sources */,
|
55B217D23023010000784731 /* TodayTrackDetailVC.swift in Sources */,
|
||||||
55B217D43023010000784733 /* TodayTrackDetailView.swift in Sources */,
|
55B217D43023010000784733 /* TodayTrackDetailView.swift in Sources */,
|
||||||
55B217D63023010000784735 /* TodayTrackDetailViewModel.swift in Sources */,
|
55B217D63023010000784735 /* TodayTrackDetailViewModel.swift in Sources */,
|
||||||
55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */,
|
|
||||||
55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */,
|
|
||||||
55B217DC302301000078473B /* PhoneReportDetailViewModel.swift in Sources */,
|
|
||||||
55B240013040000100784901 /* AppUsageReportVC.swift in Sources */,
|
55B240013040000100784901 /* AppUsageReportVC.swift in Sources */,
|
||||||
55B240033040000100784903 /* AppUsageReportView.swift in Sources */,
|
55B240033040000100784903 /* AppUsageReportView.swift in Sources */,
|
||||||
55B240053040000100784905 /* AppUsageReportViewModel.swift in Sources */,
|
55B240053040000100784905 /* AppUsageReportViewModel.swift in Sources */,
|
||||||
|
|
@ -2569,7 +2528,6 @@
|
||||||
305A76D02FCA8C7000227D26 /* MoneyFormatter.swift in Sources */,
|
305A76D02FCA8C7000227D26 /* MoneyFormatter.swift in Sources */,
|
||||||
305A76D12FCA8C7000227D26 /* TimeSpecificNotificationManager.swift in Sources */,
|
305A76D12FCA8C7000227D26 /* TimeSpecificNotificationManager.swift in Sources */,
|
||||||
305A76D22FCA8C7000227D26 /* ThemeManager.swift in Sources */,
|
305A76D22FCA8C7000227D26 /* ThemeManager.swift in Sources */,
|
||||||
55B217D13022F0A000784727 /* UnlockCountManager.swift in Sources */,
|
|
||||||
305A76D32FCA8C7000227D26 /* URLManager.swift in Sources */,
|
305A76D32FCA8C7000227D26 /* URLManager.swift in Sources */,
|
||||||
305A76D42FCA8C7000227D26 /* GroupModel.swift in Sources */,
|
305A76D42FCA8C7000227D26 /* GroupModel.swift in Sources */,
|
||||||
55B230073032000100785008 /* PigeonModel.swift in Sources */,
|
55B230073032000100785008 /* PigeonModel.swift in Sources */,
|
||||||
|
|
@ -2615,6 +2573,7 @@
|
||||||
30BAB84F2FCD2FED00C33B5C /* InviteJoinVC.swift in Sources */,
|
30BAB84F2FCD2FED00C33B5C /* InviteJoinVC.swift in Sources */,
|
||||||
305A76E52FCA8C7000227D26 /* HomeViewController.swift in Sources */,
|
305A76E52FCA8C7000227D26 /* HomeViewController.swift in Sources */,
|
||||||
305A76E62FCA8C7000227D26 /* LaunchViewController.swift in Sources */,
|
305A76E62FCA8C7000227D26 /* LaunchViewController.swift in Sources */,
|
||||||
|
55B2A1013022F0A000784901 /* LaunchPrivacyConsentView.swift in Sources */,
|
||||||
3062E8B52FCE6BBA00CEF511 /* ScanVC.swift in Sources */,
|
3062E8B52FCE6BBA00CEF511 /* ScanVC.swift in Sources */,
|
||||||
30A1C0A12FEB000100CA0001 /* CameraCaptureView.swift in Sources */,
|
30A1C0A12FEB000100CA0001 /* CameraCaptureView.swift in Sources */,
|
||||||
30A1C0A32FEB000100CA0001 /* CameraCaptureVC.swift in Sources */,
|
30A1C0A32FEB000100CA0001 /* CameraCaptureVC.swift in Sources */,
|
||||||
|
|
@ -2794,9 +2753,12 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = DeviceActivityMonitorExtension/DeviceActivityMonitorExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = DeviceActivityMonitorExtension/DeviceActivityMonitorExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_IDENTITY = "iPhone Distribution";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = LRDLWZ2Y83;
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = DeviceActivityMonitorExtension/Info.plist;
|
INFOPLIST_FILE = DeviceActivityMonitorExtension/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
|
|
@ -2808,6 +2770,8 @@
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca.DeviceActivityMonitor;
|
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca.DeviceActivityMonitor;
|
||||||
PRODUCT_NAME = DeviceActivityMonitorExtension;
|
PRODUCT_NAME = DeviceActivityMonitorExtension;
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "jsdw DeviceActivityMonitor AppStore";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||||
|
|
@ -2852,11 +2816,9 @@
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = QuickLocation/QuickLocation.entitlements;
|
CODE_SIGN_ENTITLEMENTS = QuickLocation/QuickLocation.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CODE_SIGN_STYLE = Manual;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
DEVELOPMENT_TEAM = LRDLWZ2Y83;
|
||||||
DEVELOPMENT_TEAM = "";
|
|
||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)";
|
ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = QuickLocation/Info.plist;
|
INFOPLIST_FILE = QuickLocation/Info.plist;
|
||||||
|
|
@ -2883,7 +2845,6 @@
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca;
|
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = jsdw_dev;
|
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
|
@ -2907,9 +2868,9 @@
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = QuickLocation/QuickLocation.entitlements;
|
CODE_SIGN_ENTITLEMENTS = QuickLocation/QuickLocation.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83;
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)";
|
ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)";
|
||||||
|
|
@ -2938,7 +2899,7 @@
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca;
|
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = jsdw_dev;
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = jsdw_AppStore;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
|
@ -3142,7 +3103,7 @@
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83;
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)";
|
ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)";
|
||||||
|
|
@ -3285,9 +3246,12 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = ShieldConfigurationExtension/ShieldConfigurationExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShieldConfigurationExtension/ShieldConfigurationExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_IDENTITY = "iPhone Distribution";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = LRDLWZ2Y83;
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = ShieldConfigurationExtension/Info.plist;
|
INFOPLIST_FILE = ShieldConfigurationExtension/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
|
|
@ -3299,6 +3263,8 @@
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca.ShieldConfiguration;
|
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca.ShieldConfiguration;
|
||||||
PRODUCT_NAME = ShieldConfigurationExtension;
|
PRODUCT_NAME = ShieldConfigurationExtension;
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "jsdw ShieldConfiguration AppStore";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||||
|
|
|
||||||
|
|
@ -148,8 +148,6 @@ enum GatewayStatusCode: Int {
|
||||||
case userLoginExpair3 = 1001005
|
case userLoginExpair3 = 1001005
|
||||||
// 权限不足
|
// 权限不足
|
||||||
case noAuthority = 500
|
case noAuthority = 500
|
||||||
// 审核中
|
|
||||||
case review = 201
|
|
||||||
|
|
||||||
/** ============== 未知错误 ============== */
|
/** ============== 未知错误 ============== */
|
||||||
case unknownError = -9999
|
case unknownError = -9999
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,6 @@ enum UserAPI {
|
||||||
/// 成员 APP 周期使用报告
|
/// 成员 APP 周期使用报告
|
||||||
case phoneUsagePeriod(userId: String, groupKey: String, period: String)
|
case phoneUsagePeriod(userId: String, groupKey: String, period: String)
|
||||||
|
|
||||||
/// 成员手机使用报告
|
|
||||||
case phoneUsageReport(userId: String, groupKey: String)
|
|
||||||
|
|
||||||
/// 成员历史行程
|
/// 成员历史行程
|
||||||
case phoneUsageTrips(userId: String, date: Int64)
|
case phoneUsageTrips(userId: String, date: Int64)
|
||||||
|
|
||||||
|
|
@ -164,8 +161,6 @@ extension UserAPI: MultiTargetProtocol {
|
||||||
return "mapi/phone/usage/today"
|
return "mapi/phone/usage/today"
|
||||||
case .phoneUsagePeriod:
|
case .phoneUsagePeriod:
|
||||||
return "mapi/phone/usage/period"
|
return "mapi/phone/usage/period"
|
||||||
case .phoneUsageReport:
|
|
||||||
return "mapi/phone/usage/report"
|
|
||||||
case .phoneUsageTrips:
|
case .phoneUsageTrips:
|
||||||
return "mapi/phone/usage/trips"
|
return "mapi/phone/usage/trips"
|
||||||
case .queryPhoneUsageIcon, .savePhoneUsageIcon:
|
case .queryPhoneUsageIcon, .savePhoneUsageIcon:
|
||||||
|
|
@ -229,7 +224,7 @@ extension UserAPI: MultiTargetProtocol {
|
||||||
|
|
||||||
var method: Moya.Method {
|
var method: Moya.Method {
|
||||||
switch self {
|
switch self {
|
||||||
case .userInfo, .userStatus, .phoneUsageToday, .phoneUsagePeriod, .phoneUsageReport, .phoneUsageTrips, .queryPhoneUsageIcon, .phoneLockApps, .phoneLocked, .phoneUnlockRequests, .phoneUnlockAllow, .signInInfo, .notice, .followList, .relations:
|
case .userInfo, .userStatus, .phoneUsageToday, .phoneUsagePeriod, .phoneUsageTrips, .queryPhoneUsageIcon, .phoneLockApps, .phoneLocked, .phoneUnlockRequests, .phoneUnlockAllow, .signInInfo, .notice, .followList, .relations:
|
||||||
return .get
|
return .get
|
||||||
case .changePhone, .changeAvater:
|
case .changePhone, .changeAvater:
|
||||||
return .put
|
return .put
|
||||||
|
|
@ -270,13 +265,6 @@ extension UserAPI: MultiTargetProtocol {
|
||||||
]
|
]
|
||||||
return .requestParameters(parameters: params, encoding: URLEncoding.queryString)
|
return .requestParameters(parameters: params, encoding: URLEncoding.queryString)
|
||||||
|
|
||||||
case let .phoneUsageReport(userId, groupKey):
|
|
||||||
let params: Parameters = [
|
|
||||||
"user_id": userId,
|
|
||||||
"group_key": groupKey
|
|
||||||
]
|
|
||||||
return .requestParameters(parameters: params, encoding: URLEncoding.queryString)
|
|
||||||
|
|
||||||
case let .phoneUsageTrips(userId, date):
|
case let .phoneUsageTrips(userId, date):
|
||||||
let params: Parameters = [
|
let params: Parameters = [
|
||||||
"user_id": userId,
|
"user_id": userId,
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,11 @@ import SwiftDate
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
private var didSetupPrivacyGrantedSDKs = false
|
||||||
|
|
||||||
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
|
|
||||||
UnlockCountManager.shared.start(launchOptions: launchOptions)
|
|
||||||
FontManager.registerCustomFonts()
|
FontManager.registerCustomFonts()
|
||||||
setupLocation()
|
|
||||||
ApiManager.shared.setup()
|
ApiManager.shared.setup()
|
||||||
AppRouter.shared.setup()
|
AppRouter.shared.setup()
|
||||||
AppUnlockCoordinator.shared.start()
|
AppUnlockCoordinator.shared.start()
|
||||||
|
|
@ -42,17 +41,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
IQKeyboardManager.shared.enableAutoToolbar = false
|
IQKeyboardManager.shared.enableAutoToolbar = false
|
||||||
IQKeyboardManager.shared.resignOnTouchOutside = true
|
IQKeyboardManager.shared.resignOnTouchOutside = true
|
||||||
|
|
||||||
// 微信
|
|
||||||
WXApi.registerApp(AppSettings.kAppsWXApiAppId, universalLink: AppSettings.kAppsUniversalLink)
|
WXApi.registerApp(AppSettings.kAppsWXApiAppId, universalLink: AppSettings.kAppsUniversalLink)
|
||||||
|
|
||||||
|
window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
|
window?.backgroundColor = .white
|
||||||
|
window?.overrideUserInterfaceStyle = .light
|
||||||
|
window?.rootViewController = LaunchViewController()
|
||||||
|
window?.makeKeyAndVisible()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户同意隐私协议后再初始化会采集数据的 SDK。
|
||||||
|
func setupPrivacyGrantedSDKs() {
|
||||||
|
guard !didSetupPrivacyGrantedSDKs else { return }
|
||||||
|
didSetupPrivacyGrantedSDKs = true
|
||||||
|
|
||||||
// 高德地图
|
|
||||||
#if !targetEnvironment(simulator)
|
#if !targetEnvironment(simulator)
|
||||||
AMapServices.shared().apiKey = AppSettings.kAppsAMapAppId
|
AMapServices.shared().apiKey = AppSettings.kAppsAMapAppId
|
||||||
#endif
|
|
||||||
|
|
||||||
// 个推
|
|
||||||
#if !targetEnvironment(simulator)
|
|
||||||
GeYanSdk.setPreLoginTimeout(10)
|
GeYanSdk.setPreLoginTimeout(10)
|
||||||
GeYanSdk.setEloginTimeout(10)
|
GeYanSdk.setEloginTimeout(10)
|
||||||
GeYanSdk.start(withAppId: AppSettings.kAppsGeTuiAppId) { isSuccess, error, gtcid in
|
GeYanSdk.start(withAppId: AppSettings.kAppsGeTuiAppId) { isSuccess, error, gtcid in
|
||||||
|
|
@ -63,19 +70,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// OpenIM
|
|
||||||
GroupIMService.shared.initSDK()
|
GroupIMService.shared.initSDK()
|
||||||
|
|
||||||
// 友盟
|
|
||||||
UMConfigure.initWithAppkey(AppSettings.kAppsUMengAppKey, channel: "App Store")
|
UMConfigure.initWithAppkey(AppSettings.kAppsUMengAppKey, channel: "App Store")
|
||||||
|
|
||||||
window = UIWindow(frame: UIScreen.main.bounds)
|
|
||||||
window?.backgroundColor = .white
|
|
||||||
window?.overrideUserInterfaceStyle = .light
|
|
||||||
window?.rootViewController = LaunchViewController()
|
|
||||||
window?.makeKeyAndVisible()
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
|
||||||
|
|
@ -98,15 +94,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
print("📱 continue userActivity: \(userActivity.webpageURL?.absoluteString ?? "")")
|
print("📱 continue userActivity: \(userActivity.webpageURL?.absoluteString ?? "")")
|
||||||
return WXApi.handleOpenUniversalLink(userActivity, delegate: self)
|
return WXApi.handleOpenUniversalLink(userActivity, delegate: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupLocation() {
|
|
||||||
let manager = AuthorizeManager.manager(type: .locationWhenInUse)
|
|
||||||
if manager?.authorizeStatus() != .authorized {
|
|
||||||
manager?.authorize(nil)
|
|
||||||
Permission.openAppSetting(title: "获取位置失败",
|
|
||||||
message: "请在iPhone的“设置-隐私-定位”选项中允许\(kAppName)访问你的位置。")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AppDelegate {
|
extension AppDelegate {
|
||||||
|
|
|
||||||
|
|
@ -21,30 +21,10 @@ extension DefaultsKeys {
|
||||||
var isReLogin: DefaultsKey<Bool> { .init("isReLogin", defaultValue: false) }
|
var isReLogin: DefaultsKey<Bool> { .init("isReLogin", defaultValue: false) }
|
||||||
/// 显示引导页
|
/// 显示引导页
|
||||||
var isShowGuide: DefaultsKey<Bool> { .init("isShowGuide", defaultValue: true) }
|
var isShowGuide: DefaultsKey<Bool> { .init("isShowGuide", defaultValue: true) }
|
||||||
|
/// 启动页已同意服务协议及隐私政策
|
||||||
|
var didAgreeLaunchPrivacy: DefaultsKey<Bool> { .init("didAgreeLaunchPrivacy", defaultValue: false) }
|
||||||
/// 气泡持续时间
|
/// 气泡持续时间
|
||||||
var bubbleKeepTimes: DefaultsKey<Date?> { .init("bubbleKeepTimes") }
|
var bubbleKeepTimes: DefaultsKey<Date?> { .init("bubbleKeepTimes") }
|
||||||
/// 解锁统计:今日次数
|
|
||||||
var unlockCountToday: DefaultsKey<Int> { .init("unlockCountToday", defaultValue: 0) }
|
|
||||||
/// 解锁统计:累计次数
|
|
||||||
var unlockCountTotal: DefaultsKey<Int> { .init("unlockCountTotal", defaultValue: 0) }
|
|
||||||
/// 解锁统计:日期 yyyy-MM-dd
|
|
||||||
var unlockCountDate: DefaultsKey<String> { .init("unlockCountDate", defaultValue: "") }
|
|
||||||
/// 解锁统计:按自然日累计次数,key = yyyy-MM-dd
|
|
||||||
var unlockCountByDay: DefaultsKey<[String: Int]> { .init("unlockCountByDay", defaultValue: [:]) }
|
|
||||||
/// 上次锁屏态(杀进程补计)
|
|
||||||
var unlockWasLocked: DefaultsKey<Bool> { .init("unlockWasLocked", defaultValue: false) }
|
|
||||||
var unlockLastCountedAt: DefaultsKey<Date?> { .init("unlockLastCountedAt") }
|
|
||||||
var unlockLastHeartbeatAt: DefaultsKey<Date?> { .init("unlockLastHeartbeatAt") }
|
|
||||||
var unlockLastLatitude: DefaultsKey<Double?> { .init("unlockLastLatitude") }
|
|
||||||
var unlockLastLongitude: DefaultsKey<Double?> { .init("unlockLastLongitude") }
|
|
||||||
/// 手机报告:按自然日累计屏幕时长(秒),key = yyyy-MM-dd
|
|
||||||
var screenTimeByDay: DefaultsKey<[String: Int]> { .init("screenTimeByDay", defaultValue: [:]) }
|
|
||||||
/// 手机报告:当前进程屏幕时长 session 起点(仅用于杀进程后结算到最后心跳,不跨进程续计)
|
|
||||||
var screenSessionStartAt: DefaultsKey<Date?> { .init("screenSessionStartAt") }
|
|
||||||
/// 手机报告:最后一次确认亮屏的时间(锁屏结算用,避免用 Date() 把灭屏空档算进去)
|
|
||||||
var screenLastOnAt: DefaultsKey<Date?> { .init("screenLastOnAt") }
|
|
||||||
/// 手机报告:本 App 今日进入前台次数
|
|
||||||
var appUsageToday: DefaultsKey<Int> { .init("appUsageToday", defaultValue: 0) }
|
|
||||||
/// 屏幕使用时间权限曾开启的账号
|
/// 屏幕使用时间权限曾开启的账号
|
||||||
var appRestrictApprovedUserIds: DefaultsKey<[String]> { .init("appRestrictApprovedUserIds", defaultValue: []) }
|
var appRestrictApprovedUserIds: DefaultsKey<[String]> { .init("appRestrictApprovedUserIds", defaultValue: []) }
|
||||||
/// 等待执行服务端配对全删的账号
|
/// 等待执行服务端配对全删的账号
|
||||||
|
|
@ -73,8 +53,6 @@ extension Notification.Name {
|
||||||
static let resumePopupQueue = Notification.Name("resumePopupQueue")
|
static let resumePopupQueue = Notification.Name("resumePopupQueue")
|
||||||
/// 终止弹窗队列(登录成功后)
|
/// 终止弹窗队列(登录成功后)
|
||||||
static let invalidatePopupQueue = Notification.Name("invalidatePopupQueue")
|
static let invalidatePopupQueue = Notification.Name("invalidatePopupQueue")
|
||||||
/// 本机解锁次数变化
|
|
||||||
static let unlockCountDidChange = Notification.Name("unlockCountDidChange")
|
|
||||||
/// 本机应用配对数据变化
|
/// 本机应用配对数据变化
|
||||||
static let appRestrictPairingDataDidChange = Notification.Name("appRestrictPairingDataDidChange")
|
static let appRestrictPairingDataDidChange = Notification.Name("appRestrictPairingDataDidChange")
|
||||||
/// MQTT 应用锁定状态变化
|
/// MQTT 应用锁定状态变化
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,6 @@
|
||||||
<key>NSIncludesSubdomains</key>
|
<key>NSIncludesSubdomains</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>38.207.176.65</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSIncludesSubdomains</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>imapi.zuom8.cn</key>
|
<key>imapi.zuom8.cn</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
|
|
||||||
|
|
@ -186,16 +186,16 @@ extension BaseViewController {
|
||||||
|
|
||||||
// MARK: - FLEX
|
// MARK: - FLEX
|
||||||
extension BaseViewController {
|
extension BaseViewController {
|
||||||
private func setupFlex() {
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
private func setupFlex() {
|
||||||
FLEXManager.shared.showExplorer()
|
FLEXManager.shared.showExplorer()
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
||||||
print("摇晃结束")
|
print("摇晃结束")
|
||||||
setupFlex()
|
setupFlex()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MAKR: - UIView查看大图
|
// MAKR: - UIView查看大图
|
||||||
|
|
|
||||||
|
|
@ -201,52 +201,6 @@ struct PhoneUsagePeriodModel: Mappable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PhoneUsageReportResponse: BaseModelProtocol {
|
|
||||||
var code: String?
|
|
||||||
var message: String?
|
|
||||||
var model: PhoneUsageReportModel?
|
|
||||||
|
|
||||||
init?(map: Map) {}
|
|
||||||
|
|
||||||
mutating func mapping(map: Map) {
|
|
||||||
code <- (map["code"], kIntTransformStr)
|
|
||||||
message <- map["message"]
|
|
||||||
model <- map["data"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PhoneUsageReportModel: Mappable {
|
|
||||||
var phoneInfo: PhoneUsageInfoModel?
|
|
||||||
var brand: String = ""
|
|
||||||
var brandIcon: String = ""
|
|
||||||
var screenUseTimes: [PhoneUsageReportDayModel] = []
|
|
||||||
var appUseCounts: [PhoneUsageReportDayModel] = []
|
|
||||||
|
|
||||||
init?(map: Map) {}
|
|
||||||
|
|
||||||
mutating func mapping(map: Map) {
|
|
||||||
phoneInfo <- map["phone_info"]
|
|
||||||
brand <- (map["brand"], kIntTransformStr)
|
|
||||||
brandIcon <- (map["brand_icon"], kIntTransformStr)
|
|
||||||
screenUseTimes <- map["screen_use_times"]
|
|
||||||
appUseCounts <- map["app_use_counts"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PhoneUsageReportDayModel: Mappable {
|
|
||||||
var day: String = ""
|
|
||||||
var useTime: Int?
|
|
||||||
var count: Int?
|
|
||||||
|
|
||||||
init?(map: Map) {}
|
|
||||||
|
|
||||||
mutating func mapping(map: Map) {
|
|
||||||
day <- (map["day"], kIntTransformStr)
|
|
||||||
useTime <- (map["use_time"], kStrTransformInt)
|
|
||||||
count <- (map["count"], kStrTransformInt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PhoneUsageInfoModel: Mappable {
|
struct PhoneUsageInfoModel: Mappable {
|
||||||
var brand: String = ""
|
var brand: String = ""
|
||||||
var model: String = ""
|
var model: String = ""
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class AppSettings: NSObject {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// AppStore ID
|
/// AppStore ID
|
||||||
var appId: String { "" }
|
var appId: String { "6790610409" }
|
||||||
|
|
||||||
/// UUID
|
/// UUID
|
||||||
var uuid: String = ""
|
var uuid: String = ""
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,6 @@ enum Route: String {
|
||||||
case itineraryTrace = "itineraryTrace"
|
case itineraryTrace = "itineraryTrace"
|
||||||
/// 今日轨迹详情
|
/// 今日轨迹详情
|
||||||
case todayTrackDetail = "todayTrackDetail"
|
case todayTrackDetail = "todayTrackDetail"
|
||||||
/// 手机报告详情
|
|
||||||
case phoneReportDetail = "phoneReportDetail"
|
|
||||||
/// APP 使用报告
|
/// APP 使用报告
|
||||||
case appUsageReport = "appUsageReport"
|
case appUsageReport = "appUsageReport"
|
||||||
/// 关于我们
|
/// 关于我们
|
||||||
|
|
@ -378,11 +376,6 @@ extension AppRouter: AppRouterProtocol {
|
||||||
TodayTrackDetailVC(userInfo: parameters)
|
TodayTrackDetailVC(userInfo: parameters)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 手机报告详情
|
|
||||||
AppRouter.register(Route.phoneReportDetail) { url, parameters in
|
|
||||||
PhoneReportDetailVC(userInfo: parameters)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - APP 使用报告
|
// MARK: - APP 使用报告
|
||||||
AppRouter.register(Route.appUsageReport) { _, parameters in
|
AppRouter.register(Route.appUsageReport) { _, parameters in
|
||||||
AppUsageReportVC(userInfo: parameters)
|
AppUsageReportVC(userInfo: parameters)
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,6 @@ enum MqttType: String, Codable {
|
||||||
case bubbleQuit = "bubblequit" // 退出气泡
|
case bubbleQuit = "bubblequit" // 退出气泡
|
||||||
case needTrack = "needtrack" // 追踪上报
|
case needTrack = "needtrack" // 追踪上报
|
||||||
case emote = "emote" // 接收表情
|
case emote = "emote" // 接收表情
|
||||||
case phone = "phone" // 手机信息上报
|
|
||||||
case phoneUsage = "phoneUsage" // 当前 App 使用次数上报
|
|
||||||
case lockApp = "lockApp" // 远程锁 App
|
case lockApp = "lockApp" // 远程锁 App
|
||||||
case appUnlock = "appUnlock" // 远程解锁 App
|
case appUnlock = "appUnlock" // 远程解锁 App
|
||||||
case lockAppsChanged = "lockAppsChanged" // App 锁状态已同步
|
case lockAppsChanged = "lockAppsChanged" // App 锁状态已同步
|
||||||
|
|
@ -629,76 +627,6 @@ final class AppUnlockCoordinator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 手机信息上报数据
|
|
||||||
private struct MqttPhoneInfo: Codable {
|
|
||||||
let brand: String
|
|
||||||
let model: String
|
|
||||||
let memory_total: String
|
|
||||||
let memory_used: String
|
|
||||||
let battery: String
|
|
||||||
let network: String
|
|
||||||
let brightness: String
|
|
||||||
let volume: String
|
|
||||||
let weather: String
|
|
||||||
let use_time: String
|
|
||||||
let unlock_count: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MqttPhoneReportData: Codable {
|
|
||||||
let user_id: String
|
|
||||||
let group_key: String
|
|
||||||
let phone: MqttPhoneInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MqttPhoneReportPayload: Codable {
|
|
||||||
let type: String
|
|
||||||
let data: MqttPhoneReportData
|
|
||||||
let extra: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MqttPhoneUsageExtra: Codable {
|
|
||||||
let count: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MqttPhoneUsageItem: Codable {
|
|
||||||
let day: String
|
|
||||||
let appName: String
|
|
||||||
let packageName: String
|
|
||||||
let usageTime: Int
|
|
||||||
let extra: MqttPhoneUsageExtra
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case day
|
|
||||||
case appName = "app_name"
|
|
||||||
case packageName = "package"
|
|
||||||
case usageTime = "usage_time"
|
|
||||||
case extra
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MqttPhoneUsageData: Codable {
|
|
||||||
let userId: String
|
|
||||||
let groupKey: String
|
|
||||||
let phoneUsage: [MqttPhoneUsageItem]
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case userId = "user_id"
|
|
||||||
case groupKey = "group_key"
|
|
||||||
case phoneUsage = "phone_usage"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MqttPhoneUsagePayload: Codable {
|
|
||||||
let type: String
|
|
||||||
let data: MqttPhoneUsageData
|
|
||||||
let extra: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MqttPhoneUsageSnapshot: Equatable {
|
|
||||||
let day: String
|
|
||||||
let count: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - MQTTService
|
// MARK: - MQTTService
|
||||||
|
|
||||||
/// MQTT 5.0 服务,管理连接、订阅和消息收发
|
/// MQTT 5.0 服务,管理连接、订阅和消息收发
|
||||||
|
|
@ -811,39 +739,9 @@ final class MQTTService: NSObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 手机信息上报
|
|
||||||
private let phoneReportInterval: TimeInterval = 30 * 60
|
|
||||||
private let phoneReportRetryInterval: TimeInterval = 60
|
|
||||||
private var phoneReportGroupKey = ""
|
|
||||||
private var phoneReportLocation: CLLocation?
|
|
||||||
private var phoneReportTimer: Timer?
|
|
||||||
private var lastPhoneReportDate: Date?
|
|
||||||
private var isPhoneReportInFlight = false
|
|
||||||
private var phoneReportGeneration = 0
|
|
||||||
private var lastObservedPhoneUsage: MqttPhoneUsageSnapshot?
|
|
||||||
private var pendingPhoneUsage: MqttPhoneUsageSnapshot?
|
|
||||||
|
|
||||||
override private init() {
|
override private init() {
|
||||||
super.init()
|
super.init()
|
||||||
UIDevice.current.isBatteryMonitoringEnabled = true
|
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||||
PhoneStatusCollector.shared.start()
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(applicationDidBecomeActive),
|
|
||||||
name: UIApplication.didBecomeActiveNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(appUsageCountDidChange),
|
|
||||||
name: .unlockCountDidChange,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
let usage = Self.currentPhoneUsageSnapshot()
|
|
||||||
lastObservedPhoneUsage = usage
|
|
||||||
if usage.count > 0 {
|
|
||||||
pendingPhoneUsage = usage
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 连接
|
// MARK: - 连接
|
||||||
|
|
@ -883,13 +781,11 @@ final class MQTTService: NSObject {
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
mqtt?.disconnect()
|
mqtt?.disconnect()
|
||||||
isConnected = false
|
isConnected = false
|
||||||
invalidatePhoneReportTimer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 切换用户
|
// MARK: - 切换用户
|
||||||
/// 更新 clientID 并重连(切换用户后调用)
|
/// 更新 clientID 并重连(切换用户后调用)
|
||||||
func updateClientID(_ newID: String) {
|
func updateClientID(_ newID: String) {
|
||||||
resetPhoneReportState()
|
|
||||||
clientID = newID
|
clientID = newID
|
||||||
disconnect()
|
disconnect()
|
||||||
connect()
|
connect()
|
||||||
|
|
@ -1064,206 +960,6 @@ final class MQTTService: NSObject {
|
||||||
publish(topic: "\(topic)\(AppContextManager.shared.userId)", message: payload.toJsonString())
|
publish(topic: "\(topic)\(AppContextManager.shared.userId)", message: payload.toJsonString())
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 手机信息上报
|
|
||||||
|
|
||||||
/// 首页提供当前默认圈子和最新定位。切换圈子只更新上下文,不额外触发重复上报。
|
|
||||||
func updatePhoneReportContext(groupKey: String?, location: CLLocation?) {
|
|
||||||
performOnMain { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
if let groupKey {
|
|
||||||
self.phoneReportGroupKey = groupKey
|
|
||||||
}
|
|
||||||
if let location, CLLocationCoordinate2DIsValid(location.coordinate) {
|
|
||||||
self.phoneReportLocation = location
|
|
||||||
}
|
|
||||||
self.reportPhoneUsageIfNeeded()
|
|
||||||
self.reportPhoneIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// MQTT 重连、Timer 和 App 回到前台都通过此入口检查是否到期。
|
|
||||||
func reportPhoneIfNeeded(force: Bool = false) {
|
|
||||||
guard Thread.isMainThread else {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.reportPhoneIfNeeded(force: force)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard isConnected,
|
|
||||||
!isPhoneReportInFlight,
|
|
||||||
!phoneReportGroupKey.isEmpty,
|
|
||||||
!AppContextManager.shared.userId.isEmpty else { return }
|
|
||||||
|
|
||||||
if !force,
|
|
||||||
let lastPhoneReportDate,
|
|
||||||
Date().timeIntervalSince(lastPhoneReportDate) < phoneReportInterval {
|
|
||||||
schedulePhoneReportTimer(after: phoneReportInterval - Date().timeIntervalSince(lastPhoneReportDate))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isPhoneReportInFlight = true
|
|
||||||
phoneReportGeneration += 1
|
|
||||||
let generation = phoneReportGeneration
|
|
||||||
|
|
||||||
guard let location = phoneReportLocation else {
|
|
||||||
finishPhoneReport(weather: "未知", generation: generation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
MemberWeatherService.shared.weather(for: location) { [weak self] result in
|
|
||||||
let weather = (try? result.get().text) ?? "未知"
|
|
||||||
self?.finishPhoneReport(weather: weather, generation: generation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func applicationDidBecomeActive() {
|
|
||||||
capturePhoneUsageChange()
|
|
||||||
reportPhoneUsageIfNeeded()
|
|
||||||
reportPhoneIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func appUsageCountDidChange() {
|
|
||||||
performOnMain { [weak self] in
|
|
||||||
self?.capturePhoneUsageChange()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func capturePhoneUsageChange() {
|
|
||||||
let usage = Self.currentPhoneUsageSnapshot()
|
|
||||||
guard usage != lastObservedPhoneUsage else { return }
|
|
||||||
lastObservedPhoneUsage = usage
|
|
||||||
pendingPhoneUsage = usage
|
|
||||||
reportPhoneUsageIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func reportPhoneUsageIfNeeded() {
|
|
||||||
guard Thread.isMainThread else {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.reportPhoneUsageIfNeeded()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let userId = AppContextManager.shared.userId
|
|
||||||
let groupKey = phoneReportGroupKey
|
|
||||||
guard isConnected,
|
|
||||||
let usage = pendingPhoneUsage,
|
|
||||||
!userId.isEmpty,
|
|
||||||
!groupKey.isEmpty else { return }
|
|
||||||
|
|
||||||
let item = MqttPhoneUsageItem(
|
|
||||||
day: usage.day,
|
|
||||||
appName: Self.appDisplayName,
|
|
||||||
packageName: "cn.zuom8.jisuloca",
|
|
||||||
usageTime: 0,
|
|
||||||
extra: MqttPhoneUsageExtra(count: usage.count)
|
|
||||||
)
|
|
||||||
let payload = MqttPhoneUsagePayload(
|
|
||||||
type: MqttType.phoneUsage.rawValue,
|
|
||||||
data: MqttPhoneUsageData(
|
|
||||||
userId: userId,
|
|
||||||
groupKey: groupKey,
|
|
||||||
phoneUsage: [item]
|
|
||||||
),
|
|
||||||
extra: ""
|
|
||||||
)
|
|
||||||
guard let data = try? JSONEncoder().encode(payload),
|
|
||||||
let message = String(data: data, encoding: .utf8) else { return }
|
|
||||||
|
|
||||||
let messageId = publish(
|
|
||||||
topic: "\(topic)\(userId)",
|
|
||||||
message: message
|
|
||||||
)
|
|
||||||
if messageId >= 0, pendingPhoneUsage == usage {
|
|
||||||
pendingPhoneUsage = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func finishPhoneReport(weather: String, generation: Int) {
|
|
||||||
performOnMain { [weak self] in
|
|
||||||
guard let self,
|
|
||||||
generation == self.phoneReportGeneration else { return }
|
|
||||||
self.isPhoneReportInFlight = false
|
|
||||||
|
|
||||||
let userId = AppContextManager.shared.userId
|
|
||||||
let groupKey = self.phoneReportGroupKey
|
|
||||||
guard self.isConnected, !userId.isEmpty, !groupKey.isEmpty else { return }
|
|
||||||
|
|
||||||
let payload = MqttPhoneReportPayload(
|
|
||||||
type: MqttType.phone.rawValue,
|
|
||||||
data: MqttPhoneReportData(
|
|
||||||
user_id: userId,
|
|
||||||
group_key: groupKey,
|
|
||||||
phone: self.collectPhoneInfo(weather: weather)
|
|
||||||
),
|
|
||||||
extra: ""
|
|
||||||
)
|
|
||||||
guard let data = try? JSONEncoder().encode(payload),
|
|
||||||
let message = String(data: data, encoding: .utf8) else {
|
|
||||||
self.schedulePhoneReportTimer(after: self.phoneReportRetryInterval)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let messageId = self.publish(
|
|
||||||
topic: "\(self.topic)\(userId)",
|
|
||||||
message: message
|
|
||||||
)
|
|
||||||
if messageId >= 0 {
|
|
||||||
self.lastPhoneReportDate = Date()
|
|
||||||
self.schedulePhoneReportTimer(after: self.phoneReportInterval)
|
|
||||||
} else {
|
|
||||||
self.schedulePhoneReportTimer(after: self.phoneReportRetryInterval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func collectPhoneInfo(weather: String) -> MqttPhoneInfo {
|
|
||||||
let snap = PhoneStatusCollector.shared.snapshot()
|
|
||||||
return MqttPhoneInfo(
|
|
||||||
brand: "iphone",
|
|
||||||
model: snap.model,
|
|
||||||
memory_total: snap.memoryTotal ?? "未知",
|
|
||||||
memory_used: snap.memoryUsed ?? "未知",
|
|
||||||
battery: snap.batteryText,
|
|
||||||
network: snap.network,
|
|
||||||
brightness: snap.brightnessText,
|
|
||||||
volume: snap.volumeText,
|
|
||||||
weather: weather,
|
|
||||||
use_time: "\(max(0, UnlockCountManager.shared.todayScreenTimeSeconds))",
|
|
||||||
unlock_count: "\(UnlockCountManager.shared.todayCount)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func schedulePhoneReportTimer(after interval: TimeInterval) {
|
|
||||||
invalidatePhoneReportTimer()
|
|
||||||
let timer = Timer(timeInterval: max(1, interval), repeats: false) { [weak self] _ in
|
|
||||||
self?.phoneReportTimer = nil
|
|
||||||
self?.reportPhoneIfNeeded()
|
|
||||||
}
|
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
|
||||||
phoneReportTimer = timer
|
|
||||||
}
|
|
||||||
|
|
||||||
private func invalidatePhoneReportTimer() {
|
|
||||||
performOnMain { [weak self] in
|
|
||||||
self?.phoneReportTimer?.invalidate()
|
|
||||||
self?.phoneReportTimer = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetPhoneReportState() {
|
|
||||||
performOnMain { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.phoneReportGeneration += 1
|
|
||||||
self.phoneReportGroupKey = ""
|
|
||||||
self.phoneReportLocation = nil
|
|
||||||
self.lastPhoneReportDate = nil
|
|
||||||
self.isPhoneReportInFlight = false
|
|
||||||
self.invalidatePhoneReportTimer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performOnMain(_ work: @escaping () -> Void) {
|
private func performOnMain(_ work: @escaping () -> Void) {
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
work()
|
work()
|
||||||
|
|
@ -1272,24 +968,6 @@ final class MQTTService: NSObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func currentPhoneUsageSnapshot() -> MqttPhoneUsageSnapshot {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.calendar = Calendar.current
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
formatter.timeZone = TimeZone.current
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
|
||||||
return MqttPhoneUsageSnapshot(
|
|
||||||
day: formatter.string(from: Date()),
|
|
||||||
count: max(0, UnlockCountManager.shared.todayAppUsageCount)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static var appDisplayName: String {
|
|
||||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
|
||||||
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
|
||||||
?? "极速定位"
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CocoaMQTT5Delegate
|
// MARK: - CocoaMQTT5Delegate
|
||||||
|
|
@ -1309,8 +987,6 @@ extension MQTTService: CocoaMQTT5Delegate {
|
||||||
}
|
}
|
||||||
onConnected?()
|
onConnected?()
|
||||||
flushPendingLockAppsChangedEvents()
|
flushPendingLockAppsChangedEvents()
|
||||||
reportPhoneUsageIfNeeded()
|
|
||||||
reportPhoneIfNeeded()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mqtt5(_ mqtt5: CocoaMQTT5, didPublishMessage message: CocoaMQTT5Message, id: UInt16) {
|
func mqtt5(_ mqtt5: CocoaMQTT5, didPublishMessage message: CocoaMQTT5Message, id: UInt16) {
|
||||||
|
|
@ -1359,7 +1035,6 @@ extension MQTTService: CocoaMQTT5Delegate {
|
||||||
|
|
||||||
func mqtt5DidDisconnect(_ mqtt5: CocoaMQTT5, withError err: Error?) {
|
func mqtt5DidDisconnect(_ mqtt5: CocoaMQTT5, withError err: Error?) {
|
||||||
isConnected = false
|
isConnected = false
|
||||||
invalidatePhoneReportTimer()
|
|
||||||
print("MQTT5 disconnected: \(err?.localizedDescription ?? "")")
|
print("MQTT5 disconnected: \(err?.localizedDescription ?? "")")
|
||||||
onDisconnected?()
|
onDisconnected?()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,10 +80,6 @@ final class MemberWeatherService {
|
||||||
let snapshot = Self.snapshot(from: weather)
|
let snapshot = Self.snapshot(from: weather)
|
||||||
self?.finish(key: key, result: .success(snapshot))
|
self?.finish(key: key, result: .success(snapshot))
|
||||||
} catch {
|
} catch {
|
||||||
#if DEBUG
|
|
||||||
let error = error as NSError
|
|
||||||
print("WeatherKit request failed [\(error.domain):\(error.code)] at \(key): \(error.localizedDescription)")
|
|
||||||
#endif
|
|
||||||
self?.finish(key: key, result: .failure(error))
|
self?.finish(key: key, result: .failure(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,7 @@ import SwiftyUserDefaults
|
||||||
import RxSwift
|
import RxSwift
|
||||||
|
|
||||||
extension DefaultsKeys {
|
extension DefaultsKeys {
|
||||||
// 接口环境 0:测试环境 1:正式环境
|
|
||||||
var apiEnvKey: DefaultsKey<Int> { .init("ApiEnvKey", defaultValue: -99) }
|
|
||||||
// 接口地址
|
|
||||||
var apiServerURL: DefaultsKey<String> { .init("ApiServerURL", defaultValue: "") }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objcMembers class URLManager: NSObject {
|
@objcMembers class URLManager: NSObject {
|
||||||
|
|
@ -47,64 +44,27 @@ extension DefaultsKeys {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupNetworkMode() {
|
func setupNetworkMode() {
|
||||||
#if DEBUG
|
|
||||||
setupApiEnv(1)
|
|
||||||
openIM_API = "https://imapi.zuom8.cn" // http://38.207.176.65:10002
|
|
||||||
openIM_WS = "ws://imws.zuom8.cn" // ws://38.207.176.65:10010
|
|
||||||
#elseif AdHoc
|
|
||||||
setupApiEnv(1)
|
setupApiEnv(1)
|
||||||
openIM_API = "https://imapi.zuom8.cn"
|
openIM_API = "https://imapi.zuom8.cn"
|
||||||
openIM_WS = "ws://imws.zuom8.cn"
|
openIM_WS = "ws://imws.zuom8.cn"
|
||||||
#else
|
|
||||||
setupApiEnv(1)
|
|
||||||
openIM_API = "https://imapi.zuom8.cn"
|
|
||||||
openIM_WS = "ws://imws.zuom8.cn"
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupApiEnv(_ env: Int) {
|
private func setupApiEnv(_ env: Int) {
|
||||||
// env
|
apiServerURL = apiServerURL(apiEnv)
|
||||||
if Defaults[\.apiEnvKey] != -99 {
|
|
||||||
apiEnv = Defaults[\.apiEnvKey]
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
apiEnv = env
|
|
||||||
}
|
|
||||||
// api
|
|
||||||
if Defaults[\.apiServerURL].isEmpty {
|
|
||||||
apiServerURL = apiServerURL(apiEnv)
|
|
||||||
} else {
|
|
||||||
apiServerURL = Defaults[\.apiServerURL]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 接口地址
|
// MARK: - 接口地址
|
||||||
func apiServerURL(_ env: Int) -> String {
|
func apiServerURL(_ env: Int) -> String {
|
||||||
switch env {
|
switch env {
|
||||||
case 1: // 正式
|
case 1: // 正式
|
||||||
return "https://jsapi.zuom8.cn/"
|
return "https://speed.batiao8.com/"
|
||||||
case -1: // UAT
|
case -1: // UAT
|
||||||
return "https://jsapi.zuom8.cn/"
|
return "https://speed.batiao8.com/"
|
||||||
default: // SIT
|
default: // SIT
|
||||||
return "http://172.16.10.23:9243/"
|
return "https://speed.batiao8.com/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static let presetServers: [(title: String, url: String, env: Int)] = [
|
|
||||||
("开发", "http://172.16.10.23:9243/", 0),
|
|
||||||
("正式", "https://jsapi.zuom8.cn/", 1)
|
|
||||||
]
|
|
||||||
|
|
||||||
func switchServer(url: String, env: Int? = nil) {
|
|
||||||
let normalized = Self.normalizedServerURL(url)
|
|
||||||
guard !normalized.isEmpty else { return }
|
|
||||||
let resolvedEnv = env ?? Self.env(for: normalized)
|
|
||||||
Defaults[\.apiEnvKey] = resolvedEnv
|
|
||||||
Defaults[\.apiServerURL] = normalized
|
|
||||||
apiEnv = resolvedEnv
|
|
||||||
apiServerURL = normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
static func normalizedServerURL(_ raw: String) -> String {
|
static func normalizedServerURL(_ raw: String) -> String {
|
||||||
var url = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
var url = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !url.isEmpty else { return "" }
|
guard !url.isEmpty else { return "" }
|
||||||
|
|
@ -117,20 +77,8 @@ extension DefaultsKeys {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
static func env(for url: String) -> Int {
|
|
||||||
let normalized = normalizedServerURL(url)
|
|
||||||
if let preset = presetServers.first(where: { normalizedServerURL($0.url) == normalized }) {
|
|
||||||
return preset.env
|
|
||||||
}
|
|
||||||
return 99
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 大对象服务器地址
|
// MARK: - 大对象服务器地址
|
||||||
func uploadServerURL() -> String {
|
func uploadServerURL() -> String {
|
||||||
//#if DEBUG
|
|
||||||
// return ""
|
|
||||||
//#else
|
|
||||||
return ""
|
return ""
|
||||||
//#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,889 +0,0 @@
|
||||||
//
|
|
||||||
// UnlockCountManager.swift
|
|
||||||
// QuickLocation
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import CoreLocation
|
|
||||||
import SwiftyUserDefaults
|
|
||||||
|
|
||||||
/// 本机解锁次数:protectedData 状态机 + 始终定位保持后台运行窗口(不上报)
|
|
||||||
final class UnlockCountManager: NSObject {
|
|
||||||
|
|
||||||
static let shared = UnlockCountManager()
|
|
||||||
|
|
||||||
private let locationManager = CLLocationManager()
|
|
||||||
private let coarseLocationManager = CLLocationManager()
|
|
||||||
private let logPrefix = "[DEBUG-UNLOCK]"
|
|
||||||
private let debugLogDefaultsKey = "unlockDebugLogs"
|
|
||||||
private let geofenceIdPrefix = "unlock.keepAlive."
|
|
||||||
private let geofenceCenterId = "unlock.keepAlive.center"
|
|
||||||
private let geofenceRadius: CLLocationDistance = 50
|
|
||||||
/// 四向卫星围栏相对中心的偏移(米),用于静止时 GPS 抖动触发
|
|
||||||
private let geofenceSatelliteOffset: CLLocationDistance = 40
|
|
||||||
/// 相对围栏中心移动超过该距离才刷新围栏网格,避免频繁重注册
|
|
||||||
private let geofenceRefreshDistance: CLLocationDistance = 40
|
|
||||||
|
|
||||||
private var didStart = false
|
|
||||||
private var isSessionLocked = false
|
|
||||||
private var hasSeenLockInSession = false
|
|
||||||
private var protectedDataPollTimer: Timer?
|
|
||||||
private var lastGeofenceCenter: CLLocationCoordinate2D?
|
|
||||||
private var lastKnownLocation: CLLocation?
|
|
||||||
private var bootBackgroundTask: UIBackgroundTaskIdentifier = .invalid
|
|
||||||
private var backgroundPollTask: UIBackgroundTaskIdentifier = .invalid
|
|
||||||
/// 当前解锁 session 起点(锁屏结算屏幕时长,仅当前进程)
|
|
||||||
private var screenSessionStart: Date?
|
|
||||||
/// 最后一次确认亮屏(锁屏结算用,不用 Date() 把灭屏空档算进去)
|
|
||||||
private var lastScreenOnAt: Date?
|
|
||||||
/// 亮屏心跳超过该间隔视为观察中断(可能错过锁屏)
|
|
||||||
private let screenOnStaleInterval: TimeInterval = 10
|
|
||||||
/// 本次进程里用户是否真正打开过 App;杀进程后被定位拉起不算
|
|
||||||
private var hasUserActivatedApp = false
|
|
||||||
|
|
||||||
private override init() {
|
|
||||||
super.init()
|
|
||||||
locationManager.delegate = self
|
|
||||||
configureLocationManager(
|
|
||||||
locationManager,
|
|
||||||
accuracy: kCLLocationAccuracyNearestTenMeters,
|
|
||||||
distanceFilter: kCLDistanceFilterNone
|
|
||||||
)
|
|
||||||
coarseLocationManager.delegate = self
|
|
||||||
configureLocationManager(
|
|
||||||
coarseLocationManager,
|
|
||||||
accuracy: 800,
|
|
||||||
distanceFilter: 100
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func configureLocationManager(
|
|
||||||
_ manager: CLLocationManager,
|
|
||||||
accuracy: CLLocationAccuracy,
|
|
||||||
distanceFilter: CLLocationDistance
|
|
||||||
) {
|
|
||||||
manager.desiredAccuracy = accuracy
|
|
||||||
manager.distanceFilter = distanceFilter
|
|
||||||
manager.pausesLocationUpdatesAutomatically = false
|
|
||||||
manager.activityType = .otherNavigation
|
|
||||||
if #available(iOS 11.0, *) {
|
|
||||||
manager.showsBackgroundLocationIndicator = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public
|
|
||||||
|
|
||||||
var todayCount: Int {
|
|
||||||
rollDayIfNeeded()
|
|
||||||
return Defaults[\.unlockCountToday]
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalCount: Int {
|
|
||||||
rollDayIfNeeded()
|
|
||||||
return Defaults[\.unlockCountTotal]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 今日屏幕使用时长(秒),含当前解锁 session 的今日部分
|
|
||||||
var todayScreenTimeSeconds: Int {
|
|
||||||
rollDayIfNeeded()
|
|
||||||
let today = todayString()
|
|
||||||
var total = Defaults[\.screenTimeByDay][today] ?? 0
|
|
||||||
if let start = screenSessionStart {
|
|
||||||
total += screenTimeSeconds(from: start, to: resolvedScreenSessionEnd(), onDay: today)
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 本 App 今日进入前台次数
|
|
||||||
var todayAppUsageCount: Int {
|
|
||||||
rollDayIfNeeded()
|
|
||||||
return Defaults[\.appUsageToday]
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasAlwaysLocationAuthorization: Bool {
|
|
||||||
authorizationStatus() == .authorizedAlways
|
|
||||||
}
|
|
||||||
|
|
||||||
func start(launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) {
|
|
||||||
let appState = UIApplication.shared.applicationState
|
|
||||||
let launchedByLocation = launchOptions?[.location] != nil
|
|
||||||
let nonActiveLaunch = appState != .active
|
|
||||||
|
|
||||||
if nonActiveLaunch {
|
|
||||||
beginBootBackgroundTask()
|
|
||||||
debugLog("coldBootNonActive appState=\(appState.rawValue) locationKey=\(launchedByLocation) bgMode=\(isLocationBackgroundModeEnabled) auth=\(authorizationStatus().rawValue) bootTask=1")
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !didStart else {
|
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
|
||||||
handleNonActiveLaunchIfNeeded(launchOptions)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
didStart = true
|
|
||||||
lastScreenOnAt = Defaults[\.screenLastOnAt]
|
|
||||||
closeOrphanScreenSessionIfNeeded()
|
|
||||||
rollDayIfNeeded()
|
|
||||||
|
|
||||||
let available = UIApplication.shared.isProtectedDataAvailable
|
|
||||||
isSessionLocked = !available
|
|
||||||
dumpStoredDebugLogs()
|
|
||||||
debugLog("start auth=\(authorizationStatus().rawValue) appState=\(appState.rawValue) protected=\(available) bgMode=\(isLocationBackgroundModeEnabled)")
|
|
||||||
// 冷启动已解锁:不计数,只同步状态
|
|
||||||
if !available {
|
|
||||||
hasSeenLockInSession = true
|
|
||||||
Defaults[\.unlockWasLocked] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(protectedDataWillBecomeUnavailable),
|
|
||||||
name: UIApplication.protectedDataWillBecomeUnavailableNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(protectedDataDidBecomeAvailable),
|
|
||||||
name: UIApplication.protectedDataDidBecomeAvailableNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(appWillResignActive),
|
|
||||||
name: UIApplication.willResignActiveNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(appDidEnterBackground),
|
|
||||||
name: UIApplication.didEnterBackgroundNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(appWillTerminate),
|
|
||||||
name: UIApplication.willTerminateNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(appDidBecomeActive),
|
|
||||||
name: UIApplication.didBecomeActiveNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
|
||||||
handleNonActiveLaunchIfNeeded(launchOptions)
|
|
||||||
reconcileUnlockIfNeeded(reason: "coldStartAfterLock")
|
|
||||||
if UIApplication.shared.isProtectedDataAvailable {
|
|
||||||
noteScreenOn()
|
|
||||||
}
|
|
||||||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
|
||||||
startProtectedDataPolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshAuthorizationAndMonitoring() {
|
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
|
||||||
debugLog("refresh auth=\(authorizationStatus().rawValue) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable)")
|
|
||||||
notifyChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Day roll
|
|
||||||
|
|
||||||
private func todayString() -> String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.calendar = Calendar.current
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
formatter.timeZone = TimeZone.current
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
|
||||||
return formatter.string(from: Date())
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
private func rollDayIfNeeded() -> Bool {
|
|
||||||
migrateScreenTimeStorageIfNeeded()
|
|
||||||
let today = todayString()
|
|
||||||
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
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dayString(for date: Date) -> String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.calendar = Calendar.current
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
formatter.timeZone = TimeZone.current
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
|
||||||
return formatter.string(from: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func migrateScreenTimeStorageIfNeeded() {
|
|
||||||
var byDay = Defaults[\.screenTimeByDay]
|
|
||||||
guard byDay.isEmpty else { return }
|
|
||||||
|
|
||||||
let legacyKey = "screenTimeTodaySeconds"
|
|
||||||
let legacyToday = UserDefaults.standard.integer(forKey: legacyKey)
|
|
||||||
if legacyToday > 0 {
|
|
||||||
let dateKey = Defaults[\.unlockCountDate].isEmpty ? todayString() : Defaults[\.unlockCountDate]
|
|
||||||
byDay[dateKey] = legacyToday
|
|
||||||
Defaults[\.screenTimeByDay] = byDay
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addScreenTime(from start: Date, to end: Date) {
|
|
||||||
guard end > start else { return }
|
|
||||||
var cursor = start
|
|
||||||
let calendar = Calendar.current
|
|
||||||
var byDay = Defaults[\.screenTimeByDay]
|
|
||||||
|
|
||||||
while cursor < end {
|
|
||||||
let dayStart = calendar.startOfDay(for: cursor)
|
|
||||||
guard let nextDay = calendar.date(byAdding: .day, value: 1, to: dayStart) else { break }
|
|
||||||
let segmentEnd = min(end, nextDay)
|
|
||||||
let seconds = max(0, Int(segmentEnd.timeIntervalSince(cursor)))
|
|
||||||
if seconds > 0 {
|
|
||||||
let key = dayString(for: cursor)
|
|
||||||
byDay[key, default: 0] += seconds
|
|
||||||
}
|
|
||||||
cursor = segmentEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
Defaults[\.screenTimeByDay] = byDay
|
|
||||||
}
|
|
||||||
|
|
||||||
private func screenTimeSeconds(from start: Date, to end: Date, onDay dayKey: String) -> Int {
|
|
||||||
guard end > start else { return 0 }
|
|
||||||
let calendar = Calendar.current
|
|
||||||
guard
|
|
||||||
let dayStart = date(fromDayKey: dayKey),
|
|
||||||
let nextDay = calendar.date(byAdding: .day, value: 1, to: dayStart)
|
|
||||||
else { return 0 }
|
|
||||||
|
|
||||||
let segmentStart = max(start, dayStart)
|
|
||||||
let segmentEnd = min(end, nextDay)
|
|
||||||
guard segmentEnd > segmentStart else { return 0 }
|
|
||||||
return max(0, Int(segmentEnd.timeIntervalSince(segmentStart)))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func date(fromDayKey key: String) -> Date? {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.calendar = Calendar.current
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
formatter.timeZone = TimeZone.current
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
|
||||||
return formatter.date(from: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 上一进程留下的 session 只结算到最后一次确认亮屏,死掉/锁屏的空档不再计入
|
|
||||||
private func closeOrphanScreenSessionIfNeeded() {
|
|
||||||
guard screenSessionStart == nil else { return }
|
|
||||||
guard let start = Defaults[\.screenSessionStartAt] else { return }
|
|
||||||
let end = Defaults[\.screenLastOnAt] ?? Defaults[\.unlockLastHeartbeatAt]
|
|
||||||
if let end, end > start {
|
|
||||||
addScreenTime(from: start, to: end)
|
|
||||||
}
|
|
||||||
Defaults[\.screenSessionStartAt] = nil
|
|
||||||
debugLog("closeOrphanScreenSession start=\(start) end=\(String(describing: end))")
|
|
||||||
notifyChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var shouldUseLiveScreenClock: Bool {
|
|
||||||
UIApplication.shared.applicationState == .active
|
|
||||||
&& UIApplication.shared.isProtectedDataAvailable
|
|
||||||
&& !isSessionLocked
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolvedScreenSessionEnd() -> Date {
|
|
||||||
if shouldUseLiveScreenClock {
|
|
||||||
let now = Date()
|
|
||||||
noteScreenOn(at: now)
|
|
||||||
return now
|
|
||||||
}
|
|
||||||
return lastScreenOnAt ?? screenSessionStart ?? Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func noteScreenOn(at date: Date = Date()) {
|
|
||||||
guard UIApplication.shared.isProtectedDataAvailable else { return }
|
|
||||||
lastScreenOnAt = date
|
|
||||||
Defaults[\.screenLastOnAt] = date
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startScreenSession() {
|
|
||||||
guard UIApplication.shared.isProtectedDataAvailable else { return }
|
|
||||||
guard hasUserActivatedApp || UIApplication.shared.applicationState == .active else { return }
|
|
||||||
rollDayIfNeeded()
|
|
||||||
let now = Date()
|
|
||||||
if screenSessionStart != nil {
|
|
||||||
noteScreenOn(at: now)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
screenSessionStart = now
|
|
||||||
Defaults[\.screenSessionStartAt] = now
|
|
||||||
noteScreenOn(at: now)
|
|
||||||
Defaults[\.unlockLastHeartbeatAt] = now
|
|
||||||
debugLog("startScreenSession at=\(now)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func flushScreenSession(end: Date? = nil) {
|
|
||||||
guard let start = screenSessionStart else { return }
|
|
||||||
let resolvedEnd = end ?? resolvedScreenSessionEnd()
|
|
||||||
addScreenTime(from: start, to: max(start, resolvedEnd))
|
|
||||||
screenSessionStart = nil
|
|
||||||
Defaults[\.screenSessionStartAt] = nil
|
|
||||||
debugLog("flushScreenSession start=\(start) end=\(resolvedEnd)")
|
|
||||||
notifyChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 观察中断后按最后亮屏结算,避免锁屏空档计入
|
|
||||||
private func repairOpenScreenSessionIfStale() {
|
|
||||||
guard let start = screenSessionStart else { return }
|
|
||||||
guard UIApplication.shared.isProtectedDataAvailable else {
|
|
||||||
markLocked()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let lastOn = lastScreenOnAt ?? start
|
|
||||||
let gap = Date().timeIntervalSince(lastOn)
|
|
||||||
if gap > screenOnStaleInterval {
|
|
||||||
debugLog("repairStaleScreenSession gap=\(Int(gap)) lastOn=\(lastOn)")
|
|
||||||
flushScreenSession(end: lastOn)
|
|
||||||
startScreenSession()
|
|
||||||
} else {
|
|
||||||
noteScreenOn()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func recordAppUsage() {
|
|
||||||
rollDayIfNeeded()
|
|
||||||
Defaults[\.appUsageToday] = Defaults[\.appUsageToday] + 1
|
|
||||||
notifyChange()
|
|
||||||
debugLog("appUsage today=\(Defaults[\.appUsageToday])")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Counting
|
|
||||||
|
|
||||||
private func recordUnlock(reason: String) {
|
|
||||||
let now = Date()
|
|
||||||
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
|
|
||||||
Defaults[\.unlockLastHeartbeatAt] = now
|
|
||||||
stopProtectedDataPolling()
|
|
||||||
startScreenSession()
|
|
||||||
if UIApplication.shared.applicationState != .active {
|
|
||||||
startProtectedDataPolling()
|
|
||||||
}
|
|
||||||
notifyChange()
|
|
||||||
print("🔓 UnlockCount +\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])")
|
|
||||||
debugLog("recordUnlock reason=\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func markLocked() {
|
|
||||||
if isSessionLocked, screenSessionStart == nil {
|
|
||||||
Defaults[\.unlockWasLocked] = true
|
|
||||||
hasSeenLockInSession = true
|
|
||||||
startProtectedDataPolling()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let end = lastScreenOnAt ?? screenSessionStart
|
|
||||||
flushScreenSession(end: end)
|
|
||||||
isSessionLocked = true
|
|
||||||
hasSeenLockInSession = true
|
|
||||||
Defaults[\.unlockWasLocked] = true
|
|
||||||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
|
||||||
startProtectedDataPolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistLifecycleHeartbeat() {
|
|
||||||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
|
||||||
if !UIApplication.shared.isProtectedDataAvailable {
|
|
||||||
markLocked()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
noteScreenOn()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func notifyChange() {
|
|
||||||
NotificationCenter.default.post(name: .unlockCountDidChange, object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func debugLog(_ message: String) {
|
|
||||||
let line = "\(Date()) \(logPrefix) \(message)"
|
|
||||||
NSLog("%@", line)
|
|
||||||
|
|
||||||
var logs = UserDefaults.standard.stringArray(forKey: debugLogDefaultsKey) ?? []
|
|
||||||
logs.append(line)
|
|
||||||
if logs.count > 200 {
|
|
||||||
logs.removeFirst(logs.count - 200)
|
|
||||||
}
|
|
||||||
UserDefaults.standard.set(logs, forKey: debugLogDefaultsKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dumpStoredDebugLogs() {
|
|
||||||
let logs = UserDefaults.standard.stringArray(forKey: debugLogDefaultsKey) ?? []
|
|
||||||
guard !logs.isEmpty else { return }
|
|
||||||
NSLog("%@ storedLogCount=%d", logPrefix, logs.count)
|
|
||||||
logs.suffix(50).forEach { NSLog("%@ stored %@", logPrefix, $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var shouldPollProtectedData: Bool {
|
|
||||||
let needsUnlockDetect = Defaults[\.unlockWasLocked] || isSessionLocked
|
|
||||||
let needsLockDetect = screenSessionStart != nil && UIApplication.shared.applicationState != .active
|
|
||||||
return needsUnlockDetect || needsLockDetect
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startProtectedDataPolling() {
|
|
||||||
guard shouldPollProtectedData else { return }
|
|
||||||
guard protectedDataPollTimer == nil else { return }
|
|
||||||
debugLog("startProtectedDataPolling")
|
|
||||||
|
|
||||||
let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
self.pollProtectedDataAvailability()
|
|
||||||
}
|
|
||||||
protectedDataPollTimer = timer
|
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
|
||||||
pollProtectedDataAvailability()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopProtectedDataPolling() {
|
|
||||||
guard protectedDataPollTimer != nil else { return }
|
|
||||||
protectedDataPollTimer?.invalidate()
|
|
||||||
protectedDataPollTimer = nil
|
|
||||||
debugLog("stopProtectedDataPolling")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pollProtectedDataAvailability() {
|
|
||||||
let available = UIApplication.shared.isProtectedDataAvailable
|
|
||||||
if !available {
|
|
||||||
debugLog("protectedDataPoll protected=0 wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
|
||||||
markLocked()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if Defaults[\.unlockWasLocked] || isSessionLocked {
|
|
||||||
debugLog("protectedDataPoll protected=1 wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
|
||||||
recordUnlock(reason: "protectedDataPoll")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
noteScreenOn()
|
|
||||||
if !shouldPollProtectedData {
|
|
||||||
stopProtectedDataPolling()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 对照 protectedData 纠正锁态(定位回调补强,避免挂起丢通知)
|
|
||||||
private func syncLockStateFromProtectedData(reason: String) {
|
|
||||||
if !UIApplication.shared.isProtectedDataAvailable {
|
|
||||||
markLocked()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if Defaults[\.unlockWasLocked] || isSessionLocked {
|
|
||||||
recordUnlock(reason: reason)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
repairOpenScreenSessionIfStale()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 定位事件短窗口:锁态同步 + 心跳 + FinishTask 断言
|
|
||||||
private func performLocationTick(reason: String) {
|
|
||||||
let app = UIApplication.shared
|
|
||||||
var taskId = UIBackgroundTaskIdentifier.invalid
|
|
||||||
taskId = app.beginBackgroundTask(withName: "unlock.locationTick") {
|
|
||||||
if taskId != .invalid {
|
|
||||||
app.endBackgroundTask(taskId)
|
|
||||||
taskId = .invalid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
syncLockStateFromProtectedData(reason: reason)
|
|
||||||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
|
||||||
|
|
||||||
if taskId != .invalid {
|
|
||||||
app.endBackgroundTask(taskId)
|
|
||||||
taskId = .invalid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DAS / 定位冷启动短窗口:等 locationd 接管前尽量不被秒挂
|
|
||||||
private func beginBootBackgroundTask() {
|
|
||||||
endBootBackgroundTask()
|
|
||||||
let app = UIApplication.shared
|
|
||||||
bootBackgroundTask = app.beginBackgroundTask(withName: "unlock.boot") { [weak self] in
|
|
||||||
self?.debugLog("bootTask expired")
|
|
||||||
self?.endBootBackgroundTask()
|
|
||||||
}
|
|
||||||
debugLog("bootTask begin id=\(bootBackgroundTask.rawValue)")
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 25) { [weak self] in
|
|
||||||
self?.endBootBackgroundTask()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func endBootBackgroundTask() {
|
|
||||||
guard bootBackgroundTask != .invalid else { return }
|
|
||||||
let id = bootBackgroundTask
|
|
||||||
bootBackgroundTask = .invalid
|
|
||||||
UIApplication.shared.endBackgroundTask(id)
|
|
||||||
debugLog("bootTask end id=\(id.rawValue)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 进后台后再给 poll 一段系统宽限,覆盖「回桌面马上锁屏」
|
|
||||||
private func beginBackgroundPollTask() {
|
|
||||||
endBackgroundPollTask()
|
|
||||||
let app = UIApplication.shared
|
|
||||||
backgroundPollTask = app.beginBackgroundTask(withName: "unlock.screenOnPoll") { [weak self] in
|
|
||||||
self?.debugLog("screenOnPollTask expired")
|
|
||||||
self?.endBackgroundPollTask()
|
|
||||||
}
|
|
||||||
debugLog("screenOnPollTask begin id=\(backgroundPollTask.rawValue)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func endBackgroundPollTask() {
|
|
||||||
guard backgroundPollTask != .invalid else { return }
|
|
||||||
let id = backgroundPollTask
|
|
||||||
backgroundPollTask = .invalid
|
|
||||||
UIApplication.shared.endBackgroundTask(id)
|
|
||||||
debugLog("screenOnPollTask end id=\(id.rawValue)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - protectedData / lifecycle
|
|
||||||
|
|
||||||
@objc private func protectedDataWillBecomeUnavailable() {
|
|
||||||
debugLog("protectedDataWillBecomeUnavailable appState=\(UIApplication.shared.applicationState.rawValue)")
|
|
||||||
markLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func protectedDataDidBecomeAvailable() {
|
|
||||||
debugLog("protectedDataDidBecomeAvailable appState=\(UIApplication.shared.applicationState.rawValue) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
|
||||||
guard hasSeenLockInSession || Defaults[\.unlockWasLocked] else { return }
|
|
||||||
guard isSessionLocked || Defaults[\.unlockWasLocked] else { return }
|
|
||||||
recordUnlock(reason: "protectedData")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func appWillResignActive() {
|
|
||||||
if UIApplication.shared.isProtectedDataAvailable {
|
|
||||||
noteScreenOn()
|
|
||||||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
|
||||||
} else {
|
|
||||||
markLocked()
|
|
||||||
}
|
|
||||||
debugLog("willResignActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked])")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func appDidEnterBackground() {
|
|
||||||
persistLifecycleHeartbeat()
|
|
||||||
startProtectedDataPolling()
|
|
||||||
beginBackgroundPollTask()
|
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
|
||||||
forceRefreshGeofenceFromLastKnown(reason: "didEnterBackground")
|
|
||||||
debugLog("didEnterBackground auth=\(authorizationStatus().rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) regions=\(locationManager.monitoredRegions.count)")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func appWillTerminate() {
|
|
||||||
flushScreenSession()
|
|
||||||
persistLifecycleHeartbeat()
|
|
||||||
forceRefreshGeofenceFromLastKnown(reason: "willTerminate")
|
|
||||||
debugLog("willTerminate protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked])")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func appDidBecomeActive() {
|
|
||||||
debugLog("didBecomeActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
|
||||||
hasUserActivatedApp = true
|
|
||||||
endBootBackgroundTask()
|
|
||||||
endBackgroundPollTask()
|
|
||||||
if UIApplication.shared.isProtectedDataAvailable {
|
|
||||||
if isSessionLocked || Defaults[\.unlockWasLocked] {
|
|
||||||
reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock")
|
|
||||||
} else {
|
|
||||||
repairOpenScreenSessionIfStale()
|
|
||||||
startScreenSession()
|
|
||||||
}
|
|
||||||
if !isSessionLocked {
|
|
||||||
stopProtectedDataPolling()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
markLocked()
|
|
||||||
}
|
|
||||||
recordAppUsage()
|
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
|
||||||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 杀进程前已记锁屏,解锁后冷启动/回前台时补计
|
|
||||||
private func reconcileUnlockIfNeeded(reason: String) {
|
|
||||||
guard UIApplication.shared.isProtectedDataAvailable else { return }
|
|
||||||
guard Defaults[\.unlockWasLocked] || isSessionLocked else { return }
|
|
||||||
recordUnlock(reason: reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Location wake
|
|
||||||
|
|
||||||
private func authorizationStatus() -> CLAuthorizationStatus {
|
|
||||||
if #available(iOS 14.0, *) {
|
|
||||||
return locationManager.authorizationStatus
|
|
||||||
}
|
|
||||||
return CLLocationManager.authorizationStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Info.plist 的 UIBackgroundModes 必须是含 location 的数组,才可安全打开后台持续定位
|
|
||||||
private var isLocationBackgroundModeEnabled: Bool {
|
|
||||||
guard let modes = Bundle.main.object(forInfoDictionaryKey: "UIBackgroundModes") as? [String] else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return modes.contains("location")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func restartLocationMonitoringIfPossible(reassertContinuous: Bool = false) {
|
|
||||||
guard authorizationStatus() == .authorizedAlways else {
|
|
||||||
stopAllLocationMonitoring()
|
|
||||||
stopProtectedDataPolling()
|
|
||||||
debugLog("stopLocation auth=\(authorizationStatus().rawValue) stopVisits=true stopCoarse=true stopGeofence=true")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重要位置变化 / Visit / 围栏:杀进程后由系统拉起
|
|
||||||
locationManager.startMonitoringSignificantLocationChanges()
|
|
||||||
locationManager.startMonitoringVisits()
|
|
||||||
debugLog("startVisits auth=\(authorizationStatus().rawValue)")
|
|
||||||
|
|
||||||
if isLocationBackgroundModeEnabled {
|
|
||||||
reassertContinuousLocationUpdates(forceStopFirst: reassertContinuous)
|
|
||||||
} else {
|
|
||||||
if #available(iOS 9.0, *) {
|
|
||||||
locationManager.allowsBackgroundLocationUpdates = false
|
|
||||||
coarseLocationManager.allowsBackgroundLocationUpdates = false
|
|
||||||
}
|
|
||||||
locationManager.stopUpdatingLocation()
|
|
||||||
coarseLocationManager.stopUpdatingLocation()
|
|
||||||
print("UnlockCountManager: UIBackgroundModes 缺少 location,仅使用重要位置变化/围栏")
|
|
||||||
debugLog("startSignificantOnly auth=\(authorizationStatus().rawValue)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopAllLocationMonitoring() {
|
|
||||||
locationManager.stopUpdatingLocation()
|
|
||||||
locationManager.stopMonitoringSignificantLocationChanges()
|
|
||||||
locationManager.stopMonitoringVisits()
|
|
||||||
coarseLocationManager.stopUpdatingLocation()
|
|
||||||
stopGeofenceMonitoring()
|
|
||||||
if #available(iOS 9.0, *) {
|
|
||||||
locationManager.allowsBackgroundLocationUpdates = false
|
|
||||||
coarseLocationManager.allowsBackgroundLocationUpdates = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 停再开持续定位,迫使 locationd 重新挂上 Location subscription(对齐 Lookus 拉起后行为)
|
|
||||||
private func reassertContinuousLocationUpdates(forceStopFirst: Bool) {
|
|
||||||
guard authorizationStatus() == .authorizedAlways, isLocationBackgroundModeEnabled else { return }
|
|
||||||
if forceStopFirst {
|
|
||||||
locationManager.stopUpdatingLocation()
|
|
||||||
coarseLocationManager.stopUpdatingLocation()
|
|
||||||
debugLog("reassertContinuous stopThenStart")
|
|
||||||
}
|
|
||||||
if #available(iOS 9.0, *) {
|
|
||||||
locationManager.allowsBackgroundLocationUpdates = true
|
|
||||||
coarseLocationManager.allowsBackgroundLocationUpdates = true
|
|
||||||
}
|
|
||||||
locationManager.startUpdatingLocation()
|
|
||||||
coarseLocationManager.startUpdatingLocation()
|
|
||||||
debugLog("startPreciseLocation accuracy=10 distanceFilter=none activity=otherNavigation auth=\(authorizationStatus().rawValue) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable)")
|
|
||||||
debugLog("startCoarseLocation accuracy=800 distanceFilter=100 activity=otherNavigation auth=\(authorizationStatus().rawValue) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopGeofenceMonitoring() {
|
|
||||||
for region in locationManager.monitoredRegions where region.identifier.hasPrefix(geofenceIdPrefix) {
|
|
||||||
locationManager.stopMonitoring(for: region)
|
|
||||||
}
|
|
||||||
lastGeofenceCenter = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 中心 + 东南西北共 5 个围栏中心点(偏移约 40m)
|
|
||||||
private func geofenceGridCenters(around coordinate: CLLocationCoordinate2D) -> [(id: String, coordinate: CLLocationCoordinate2D)] {
|
|
||||||
let earthRadius: CLLocationDistance = 6_378_137
|
|
||||||
let latRad = coordinate.latitude * .pi / 180
|
|
||||||
let dLat = (geofenceSatelliteOffset / earthRadius) * 180 / .pi
|
|
||||||
let dLon = (geofenceSatelliteOffset / (earthRadius * cos(latRad))) * 180 / .pi
|
|
||||||
return [
|
|
||||||
(geofenceCenterId, coordinate),
|
|
||||||
("\(geofenceIdPrefix)n", CLLocationCoordinate2D(latitude: coordinate.latitude + dLat, longitude: coordinate.longitude)),
|
|
||||||
("\(geofenceIdPrefix)s", CLLocationCoordinate2D(latitude: coordinate.latitude - dLat, longitude: coordinate.longitude)),
|
|
||||||
("\(geofenceIdPrefix)e", CLLocationCoordinate2D(latitude: coordinate.latitude, longitude: coordinate.longitude + dLon)),
|
|
||||||
("\(geofenceIdPrefix)w", CLLocationCoordinate2D(latitude: coordinate.latitude, longitude: coordinate.longitude - dLon))
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private func rememberLocation(_ location: CLLocation) {
|
|
||||||
lastKnownLocation = location
|
|
||||||
Defaults[\.unlockLastLatitude] = location.coordinate.latitude
|
|
||||||
Defaults[\.unlockLastLongitude] = location.coordinate.longitude
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolvedLastKnownLocation() -> CLLocation? {
|
|
||||||
if let location = locationManager.location ?? lastKnownLocation {
|
|
||||||
return location
|
|
||||||
}
|
|
||||||
if let lat = Defaults[\.unlockLastLatitude], let lon = Defaults[\.unlockLastLongitude] {
|
|
||||||
return CLLocation(latitude: lat, longitude: lon)
|
|
||||||
}
|
|
||||||
if let lat = Defaults[\.currentLatitude], let lon = Defaults[\.currentLongitude] {
|
|
||||||
return CLLocation(latitude: lat, longitude: lon)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func forceRefreshGeofenceFromLastKnown(reason: String) {
|
|
||||||
guard let location = resolvedLastKnownLocation() else {
|
|
||||||
debugLog("geofenceSkip reason=\(reason) noLastLocation")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
debugLog("geofenceForce reason=\(reason)")
|
|
||||||
refreshGeofence(around: location, force: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshGeofence(around location: CLLocation, force: Bool = false) {
|
|
||||||
guard authorizationStatus() == .authorizedAlways else { return }
|
|
||||||
guard CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) else {
|
|
||||||
debugLog("geofence unavailable")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let coordinate = location.coordinate
|
|
||||||
if !force, let last = lastGeofenceCenter {
|
|
||||||
let lastLocation = CLLocation(latitude: last.latitude, longitude: last.longitude)
|
|
||||||
if lastLocation.distance(from: location) < geofenceRefreshDistance {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stopGeofenceMonitoring()
|
|
||||||
|
|
||||||
let centers = geofenceGridCenters(around: coordinate)
|
|
||||||
for item in centers {
|
|
||||||
let region = CLCircularRegion(
|
|
||||||
center: item.coordinate,
|
|
||||||
radius: geofenceRadius,
|
|
||||||
identifier: item.id
|
|
||||||
)
|
|
||||||
region.notifyOnEntry = true
|
|
||||||
region.notifyOnExit = true
|
|
||||||
locationManager.startMonitoring(for: region)
|
|
||||||
}
|
|
||||||
lastGeofenceCenter = coordinate
|
|
||||||
let ours = locationManager.monitoredRegions.filter { $0.identifier.hasPrefix(geofenceIdPrefix) }.count
|
|
||||||
debugLog("geofenceRefresh lat=\(coordinate.latitude) lon=\(coordinate.longitude) radius=\(geofenceRadius) grid=\(centers.count) force=\(force) monitoredOurs=\(ours) monitoredTotal=\(locationManager.monitoredRegions.count)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DAS 预热 / 定位拉起 / 任意非前台冷启动:立刻重申持续定位并尝试恢复围栏
|
|
||||||
private func handleNonActiveLaunchIfNeeded(_ launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
|
|
||||||
let launchedByLocation = launchOptions?[.location] != nil
|
|
||||||
let nonActive = UIApplication.shared.applicationState != .active
|
|
||||||
guard launchedByLocation || nonActive else { return }
|
|
||||||
|
|
||||||
let reason: String
|
|
||||||
if launchedByLocation {
|
|
||||||
reason = "locationLaunch"
|
|
||||||
} else if UIApplication.shared.applicationState == .background {
|
|
||||||
reason = "backgroundLaunch"
|
|
||||||
} else {
|
|
||||||
reason = "inactiveLaunch"
|
|
||||||
}
|
|
||||||
|
|
||||||
debugLog("handleLaunch reason=\(reason) locationKey=\(launchedByLocation) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) bgMode=\(isLocationBackgroundModeEnabled)")
|
|
||||||
reassertContinuousLocationUpdates(forceStopFirst: true)
|
|
||||||
forceRefreshGeofenceFromLastKnown(reason: reason)
|
|
||||||
performLocationTick(reason: reason)
|
|
||||||
startProtectedDataPolling()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CLLocationManagerDelegate
|
|
||||||
|
|
||||||
extension UnlockCountManager: CLLocationManagerDelegate {
|
|
||||||
|
|
||||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
|
||||||
debugLog("authChangedNewAPI auth=\(authorizationStatus().rawValue)")
|
|
||||||
notifyChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
|
||||||
debugLog("authChangedOldAPI status=\(status.rawValue)")
|
|
||||||
notifyChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
||||||
let source = manager === coarseLocationManager ? "coarse" : "precise"
|
|
||||||
debugLog("didUpdateLocations source=\(source) count=\(locations.count) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
|
||||||
performLocationTick(reason: "locationUpdate")
|
|
||||||
if let latest = locations.last {
|
|
||||||
rememberLocation(latest)
|
|
||||||
refreshGeofence(around: latest)
|
|
||||||
}
|
|
||||||
// locationd 已开始送点,可结束 DAS 预热 boot task
|
|
||||||
endBootBackgroundTask()
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
|
|
||||||
debugLog("didVisit arrival=\(visit.arrivalDate) departure=\(visit.departureDate) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
|
||||||
beginBootBackgroundTask()
|
|
||||||
reassertContinuousLocationUpdates(forceStopFirst: true)
|
|
||||||
performLocationTick(reason: "visit")
|
|
||||||
if visit.coordinate.latitude != 0 || visit.coordinate.longitude != 0 {
|
|
||||||
let location = CLLocation(latitude: visit.coordinate.latitude, longitude: visit.coordinate.longitude)
|
|
||||||
rememberLocation(location)
|
|
||||||
refreshGeofence(around: location, force: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
|
|
||||||
guard region.identifier.hasPrefix(geofenceIdPrefix) else { return }
|
|
||||||
debugLog("didEnterRegion id=\(region.identifier) appState=\(UIApplication.shared.applicationState.rawValue)")
|
|
||||||
beginBootBackgroundTask()
|
|
||||||
reassertContinuousLocationUpdates(forceStopFirst: true)
|
|
||||||
performLocationTick(reason: "geofenceEnter")
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
|
|
||||||
guard region.identifier.hasPrefix(geofenceIdPrefix) else { return }
|
|
||||||
debugLog("didExitRegion id=\(region.identifier) appState=\(UIApplication.shared.applicationState.rawValue)")
|
|
||||||
beginBootBackgroundTask()
|
|
||||||
reassertContinuousLocationUpdates(forceStopFirst: true)
|
|
||||||
performLocationTick(reason: "geofenceExit")
|
|
||||||
if let location = manager.location {
|
|
||||||
rememberLocation(location)
|
|
||||||
refreshGeofence(around: location, force: true)
|
|
||||||
} else {
|
|
||||||
forceRefreshGeofenceFromLastKnown(reason: "geofenceExit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didStartMonitoringFor region: CLRegion) {
|
|
||||||
debugLog("geofenceStarted id=\(region.identifier)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error) {
|
|
||||||
debugLog("geofenceFail id=\(region?.identifier ?? "nil") error=\(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
||||||
print("UnlockCountManager location error: \(error.localizedDescription)")
|
|
||||||
debugLog("locationError=\(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -255,20 +255,43 @@ enum HeadPic {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum HeadPicAssociatedKeys {
|
||||||
|
static var lastValue: UInt8 = 0
|
||||||
|
}
|
||||||
|
|
||||||
extension UIImageView {
|
extension UIImageView {
|
||||||
|
private var lastHeadPicValue: String {
|
||||||
|
get { objc_getAssociatedObject(self, &HeadPicAssociatedKeys.lastValue) as? String ?? "" }
|
||||||
|
set { objc_setAssociatedObject(self, &HeadPicAssociatedKeys.lastValue, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) }
|
||||||
|
}
|
||||||
|
|
||||||
func setHeadPic(_ value: String?) {
|
func setHeadPic(_ value: String?) {
|
||||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else {
|
guard !trimmed.isEmpty else {
|
||||||
|
lastHeadPicValue = ""
|
||||||
kf.cancelDownloadTask()
|
kf.cancelDownloadTask()
|
||||||
image = HeadPic.placeholder
|
image = HeadPic.placeholder
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let cached = HeadPic.image(for: trimmed)
|
||||||
|
let hasCachedImage = cached.size.width > 0
|
||||||
|
if lastHeadPicValue == trimmed, hasCachedImage, image === cached {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastHeadPicValue = trimmed
|
||||||
if let url = HeadPic.remoteURL(for: trimmed) {
|
if let url = HeadPic.remoteURL(for: trimmed) {
|
||||||
kf.cancelDownloadTask()
|
if !hasCachedImage {
|
||||||
kf.setImage(with: url, placeholder: nil, options: kNetworkImageFailureOptions)
|
kf.cancelDownloadTask()
|
||||||
|
image = nil
|
||||||
|
}
|
||||||
|
kf.setImage(
|
||||||
|
with: url,
|
||||||
|
placeholder: hasCachedImage ? cached : nil,
|
||||||
|
options: kNetworkImageFailureOptions
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
kf.cancelDownloadTask()
|
kf.cancelDownloadTask()
|
||||||
image = HeadPic.localImage(trimmed)
|
image = cached
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,140 @@
|
||||||
<key>NSPrivacyTrackingDomains</key>
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
<array/>
|
<array/>
|
||||||
<key>NSPrivacyCollectedDataTypes</key>
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
<array/>
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypePreciseLocation</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypePhysicalAddress</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeUserID</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeDeviceID</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypePhoneNumber</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeName</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeEmailAddress</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypePhotosorVideos</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeAudioData</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeContacts</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeOtherUserContent</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>NSPrivacyAccessedAPITypes</key>
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
final class GroupMemberListView: UIView {
|
final class GroupMemberListView: UIView {
|
||||||
|
|
||||||
|
|
@ -257,6 +258,7 @@ final class GroupMemberListCell: UICollectionViewCell {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
updateSOSBlink(false)
|
updateSOSBlink(false)
|
||||||
bubbleImageView.isHidden = true
|
bubbleImageView.isHidden = true
|
||||||
|
avatarImageView.kf.cancelDownloadTask()
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(model: GroupMemberModel, isCurrentUser: Bool, isSelected: Bool) {
|
func configure(model: GroupMemberModel, isCurrentUser: Bool, isSelected: Bool) {
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,8 @@ class GroupMemberView2: UIView {
|
||||||
private var navBarHeightConstraint: NSLayoutConstraint?
|
private var navBarHeightConstraint: NSLayoutConstraint?
|
||||||
private var todayTrackTopToInteraction: NSLayoutConstraint?
|
private var todayTrackTopToInteraction: NSLayoutConstraint?
|
||||||
private var todayTrackTopToMember: NSLayoutConstraint?
|
private var todayTrackTopToMember: NSLayoutConstraint?
|
||||||
private var appUsageTopToPhoneReportConstraint: NSLayoutConstraint?
|
|
||||||
private var appUsageTopToTodayTrackConstraint: NSLayoutConstraint?
|
private var appUsageTopToTodayTrackConstraint: NSLayoutConstraint?
|
||||||
private var todayTrackBottomConstraint: NSLayoutConstraint?
|
private var todayTrackBottomConstraint: NSLayoutConstraint?
|
||||||
private var phoneReportBottomConstraint: NSLayoutConstraint?
|
|
||||||
private var appUsageBottomConstraint: NSLayoutConstraint?
|
private var appUsageBottomConstraint: NSLayoutConstraint?
|
||||||
private var isAppUsageVisible = false
|
private var isAppUsageVisible = false
|
||||||
private var currentMemberModel: GroupMemberModel?
|
private var currentMemberModel: GroupMemberModel?
|
||||||
|
|
@ -210,42 +208,32 @@ class GroupMemberView2: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if isShowingOfflineOther {
|
if isShowingOfflineOther {
|
||||||
phoneReportView.configure(with: nil)
|
|
||||||
appUsageView.configure(with: nil)
|
appUsageView.configure(with: nil)
|
||||||
applyFeatureAccessStates(appUsagePreview: nil)
|
applyFeatureAccessStates(appUsagePreview: nil)
|
||||||
applyOfflineStayPoints()
|
applyOfflineStayPoints()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
phoneReportView.configure(with: Self.phoneReportPreview(from: usage))
|
|
||||||
let appUsagePreview = MemberAppUsagePreview(topApps: usage?.topApps ?? [])
|
let appUsagePreview = MemberAppUsagePreview(topApps: usage?.topApps ?? [])
|
||||||
appUsageView.configure(with: appUsagePreview)
|
appUsageView.configure(with: appUsagePreview)
|
||||||
applyFeatureAccessStates(appUsagePreview: appUsagePreview)
|
applyFeatureAccessStates(appUsagePreview: appUsagePreview)
|
||||||
applyStayPoints(usage?.stayPoints ?? [])
|
applyStayPoints(usage?.stayPoints ?? [])
|
||||||
}
|
}
|
||||||
|
|
||||||
var isPhoneReportDenied: Bool { phoneReportView.isDenied }
|
|
||||||
var isAppUsageDenied: Bool { appUsageView.isDenied }
|
var isAppUsageDenied: Bool { appUsageView.isDenied }
|
||||||
var onPhoneReportRemind: (() -> Void)?
|
|
||||||
var onAppUsageRemind: (() -> Void)?
|
var onAppUsageRemind: (() -> Void)?
|
||||||
|
|
||||||
private func applyFeatureAccessStates(appUsagePreview: MemberAppUsagePreview?) {
|
private func applyFeatureAccessStates(appUsagePreview: MemberAppUsagePreview?) {
|
||||||
guard currentMemberModel != nil else {
|
guard currentMemberModel != nil else {
|
||||||
phoneReportView.setAccessState(.available)
|
|
||||||
appUsageView.setAccessState(.available)
|
appUsageView.setAccessState(.available)
|
||||||
updateAppUsageVisibility(appUsagePreview != nil)
|
updateAppUsageVisibility(appUsagePreview != nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let isSelf = isShowingCurrentUser
|
let isSelf = isShowingCurrentUser
|
||||||
let memberPrivilege = currentMemberModel?.extra.privilege ?? GroupPrivilege()
|
let memberPrivilege = currentMemberModel?.extra.privilege ?? GroupPrivilege()
|
||||||
let phoneState = Self.accessState(
|
|
||||||
privilegeAllowed: isSelf || memberPrivilege.phone_report
|
|
||||||
)
|
|
||||||
let appUsageState = Self.accessState(
|
let appUsageState = Self.accessState(
|
||||||
privilegeAllowed: isSelf || memberPrivilege.app_usage_record
|
privilegeAllowed: isSelf || memberPrivilege.app_usage_record
|
||||||
)
|
)
|
||||||
|
|
||||||
phoneReportView.setAccessState(phoneState)
|
|
||||||
phoneReportView.onDeniedAction = { [weak self] in self?.onPhoneReportRemind?() }
|
|
||||||
appUsageView.setAccessState(appUsageState)
|
appUsageView.setAccessState(appUsageState)
|
||||||
appUsageView.onDeniedAction = { [weak self] in self?.onAppUsageRemind?() }
|
appUsageView.onDeniedAction = { [weak self] in self?.onAppUsageRemind?() }
|
||||||
let showUsage = appUsagePreview != nil || appUsageState != .available
|
let showUsage = appUsagePreview != nil || appUsageState != .available
|
||||||
|
|
@ -399,16 +387,10 @@ class GroupMemberView2: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateFeatureVisibilityLayout() {
|
private func updateFeatureVisibilityLayout() {
|
||||||
let hidesPhoneReport = false
|
appUsageView.isHidden = !isAppUsageVisible
|
||||||
let showsAppUsage = !hidesPhoneReport && isAppUsageVisible
|
appUsageTopToTodayTrackConstraint?.isActive = true
|
||||||
phoneReportView.isHidden = hidesPhoneReport
|
appUsageBottomConstraint?.isActive = isAppUsageVisible
|
||||||
appUsageView.isHidden = !showsAppUsage
|
todayTrackBottomConstraint?.isActive = !isAppUsageVisible
|
||||||
|
|
||||||
appUsageTopToPhoneReportConstraint?.isActive = !hidesPhoneReport
|
|
||||||
appUsageTopToTodayTrackConstraint?.isActive = hidesPhoneReport
|
|
||||||
phoneReportBottomConstraint?.isActive = !hidesPhoneReport && !isAppUsageVisible
|
|
||||||
appUsageBottomConstraint?.isActive = showsAppUsage
|
|
||||||
todayTrackBottomConstraint?.isActive = hidesPhoneReport
|
|
||||||
setNeedsLayout()
|
setNeedsLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -472,23 +454,6 @@ class GroupMemberView2: UIView {
|
||||||
return min(max(percent, 0), 100)
|
return min(max(percent, 0), 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func phoneReportPreview(from usage: PhoneUsageTodayModel?) -> MemberPhoneReportPreview? {
|
|
||||||
guard let usage else { return nil }
|
|
||||||
let info = usage.phoneInfo
|
|
||||||
return MemberPhoneReportPreview(
|
|
||||||
screenTimeSeconds: screenTimeSeconds(from: info?.useTime),
|
|
||||||
usageCount: usage.appCount,
|
|
||||||
unlockCount: Int(info?.unlockCount.trimmed ?? "")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func screenTimeSeconds(from value: String?) -> Int? {
|
|
||||||
guard let value else { return nil }
|
|
||||||
let text = value.trimmed
|
|
||||||
guard !text.isEmpty, let seconds = Int(text) else { return nil }
|
|
||||||
return max(0, seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyStayPoints(_ stayPoints: [PhoneUsageStayPointModel]) {
|
private func applyStayPoints(_ stayPoints: [PhoneUsageStayPointModel]) {
|
||||||
stayNumLab.text = "\(stayPoints.count)"
|
stayNumLab.text = "\(stayPoints.count)"
|
||||||
guard let latest = stayPoints.max(by: {
|
guard let latest = stayPoints.max(by: {
|
||||||
|
|
@ -619,28 +584,14 @@ class GroupMemberView2: UIView {
|
||||||
.edgesHorzontal()
|
.edgesHorzontal()
|
||||||
todayTrackTopToMember = todayTrackView.jh_pinEdge(.top, toEdge: .bottom, of: memberView, offset: 12)
|
todayTrackTopToMember = todayTrackView.jh_pinEdge(.top, toEdge: .bottom, of: memberView, offset: 12)
|
||||||
|
|
||||||
scrollContentView.addSubview(phoneReportView)
|
|
||||||
phoneReportView.layoutChain
|
|
||||||
.topToBottomOfView(todayTrackView, offset: 15)
|
|
||||||
.edgesHorzontal()
|
|
||||||
|
|
||||||
scrollContentView.addSubview(appUsageView)
|
scrollContentView.addSubview(appUsageView)
|
||||||
appUsageView.layoutChain
|
appUsageView.layoutChain
|
||||||
.edgesHorzontal()
|
.edgesHorzontal()
|
||||||
appUsageTopToPhoneReportConstraint = appUsageView.topAnchor.constraint(
|
|
||||||
equalTo: phoneReportView.bottomAnchor,
|
|
||||||
constant: 15
|
|
||||||
)
|
|
||||||
appUsageTopToPhoneReportConstraint?.isActive = true
|
|
||||||
appUsageTopToTodayTrackConstraint = appUsageView.topAnchor.constraint(
|
appUsageTopToTodayTrackConstraint = appUsageView.topAnchor.constraint(
|
||||||
equalTo: todayTrackView.bottomAnchor,
|
equalTo: todayTrackView.bottomAnchor,
|
||||||
constant: 15
|
constant: 15
|
||||||
)
|
)
|
||||||
|
appUsageTopToTodayTrackConstraint?.isActive = true
|
||||||
phoneReportBottomConstraint = phoneReportView.bottomAnchor.constraint(
|
|
||||||
equalTo: scrollContentView.bottomAnchor,
|
|
||||||
constant: -(49 + kSafeBottomMargin)
|
|
||||||
)
|
|
||||||
appUsageBottomConstraint = appUsageView.bottomAnchor.constraint(
|
appUsageBottomConstraint = appUsageView.bottomAnchor.constraint(
|
||||||
equalTo: scrollContentView.bottomAnchor,
|
equalTo: scrollContentView.bottomAnchor,
|
||||||
constant: -(49 + kSafeBottomMargin)
|
constant: -(49 + kSafeBottomMargin)
|
||||||
|
|
@ -649,7 +600,7 @@ class GroupMemberView2: UIView {
|
||||||
equalTo: scrollContentView.bottomAnchor,
|
equalTo: scrollContentView.bottomAnchor,
|
||||||
constant: -(49 + kSafeBottomMargin)
|
constant: -(49 + kSafeBottomMargin)
|
||||||
)
|
)
|
||||||
phoneReportBottomConstraint?.isActive = true
|
todayTrackBottomConstraint?.isActive = true
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
@ -1191,8 +1142,7 @@ class GroupMemberView2: UIView {
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: - 手机报告 / APP使用记录
|
// MARK: - APP使用记录
|
||||||
lazy var phoneReportView = MemberPhoneReportView()
|
|
||||||
lazy var appUsageView: MemberAppUsageView = {
|
lazy var appUsageView: MemberAppUsageView = {
|
||||||
let view = MemberAppUsageView()
|
let view = MemberAppUsageView()
|
||||||
view.isHidden = true
|
view.isHidden = true
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ class HomeViewController: BaseViewController {
|
||||||
private var groupInfoRequestID = UUID()
|
private var groupInfoRequestID = UUID()
|
||||||
private var pendingGroupDataChanges: [GroupDataChange] = []
|
private var pendingGroupDataChanges: [GroupDataChange] = []
|
||||||
private var hasAppeared = false
|
private var hasAppeared = false
|
||||||
|
private var didRequestWhenInUseLocation = false
|
||||||
|
|
||||||
private struct GroupDataChange {
|
private struct GroupDataChange {
|
||||||
let groupKey: String
|
let groupKey: String
|
||||||
|
|
@ -123,12 +124,9 @@ class HomeViewController: BaseViewController {
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
UnlockCountManager.shared.refreshAuthorizationAndMonitoring()
|
|
||||||
MQTTService.shared.applicationDidBecomeActive()
|
|
||||||
rootView.groupMemberView.refreshLocalDeviceInfoIfNeeded()
|
rootView.groupMemberView.refreshLocalDeviceInfoIfNeeded()
|
||||||
|
requestWhenInUseLocationIfNeeded()
|
||||||
let manager = AuthorizeManager.manager(type: .locationAlways)
|
refreshLocationWarning()
|
||||||
rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized
|
|
||||||
refreshPigeonInbox()
|
refreshPigeonInbox()
|
||||||
queryUnlockRequests()
|
queryUnlockRequests()
|
||||||
if hasAppeared {
|
if hasAppeared {
|
||||||
|
|
@ -143,6 +141,21 @@ class HomeViewController: BaseViewController {
|
||||||
// sosPlayer?.stop()
|
// sosPlayer?.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func requestWhenInUseLocationIfNeeded() {
|
||||||
|
guard !didRequestWhenInUseLocation else { return }
|
||||||
|
let manager = AuthorizeManager.manager(type: .locationWhenInUse)
|
||||||
|
guard manager?.authorizeStatus() == .notDetermined else { return }
|
||||||
|
didRequestWhenInUseLocation = true
|
||||||
|
manager?.authorize { [weak self] _ in
|
||||||
|
self?.refreshLocationWarning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshLocationWarning() {
|
||||||
|
let manager = AuthorizeManager.manager(type: .locationAlways)
|
||||||
|
rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - MQTT 位置上报
|
// MARK: - MQTT 位置上报
|
||||||
private let geocoder = CLGeocoder()
|
private let geocoder = CLGeocoder()
|
||||||
private var lastGeocodedCoord: CLLocationCoordinate2D?
|
private var lastGeocodedCoord: CLLocationCoordinate2D?
|
||||||
|
|
@ -341,13 +354,6 @@ class HomeViewController: BaseViewController {
|
||||||
self?.openMessagePopView()
|
self?.openMessagePopView()
|
||||||
}).disposed(by: disposeBag)
|
}).disposed(by: disposeBag)
|
||||||
|
|
||||||
// 手机报告二级页
|
|
||||||
rootView.groupMemberView.phoneReportView.rx.tapGesture.subscribe(onNext: { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
guard !self.rootView.groupMemberView.isPhoneReportDenied else { return }
|
|
||||||
self.openPhoneReportDetail()
|
|
||||||
}).disposed(by: disposeBag)
|
|
||||||
|
|
||||||
// APP 报告二级页
|
// APP 报告二级页
|
||||||
rootView.groupMemberView.appUsageView.rx.tapGesture.subscribe(onNext: { [weak self] _ in
|
rootView.groupMemberView.appUsageView.rx.tapGesture.subscribe(onNext: { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
@ -355,9 +361,6 @@ class HomeViewController: BaseViewController {
|
||||||
self.openAppUsageReport()
|
self.openAppUsageReport()
|
||||||
}).disposed(by: disposeBag)
|
}).disposed(by: disposeBag)
|
||||||
|
|
||||||
rootView.groupMemberView.onPhoneReportRemind = { [weak self] in
|
|
||||||
self?.remindSelectedMember(content: "请开启手机报告隐私设置")
|
|
||||||
}
|
|
||||||
rootView.groupMemberView.onAppUsageRemind = { [weak self] in
|
rootView.groupMemberView.onAppUsageRemind = { [weak self] in
|
||||||
self?.remindSelectedMember(content: "请开启App使用记录隐私设置")
|
self?.remindSelectedMember(content: "请开启App使用记录隐私设置")
|
||||||
}
|
}
|
||||||
|
|
@ -677,11 +680,6 @@ class HomeViewController: BaseViewController {
|
||||||
rootView.groupMemberView.groupNameLab.text = viewModel.groupName.isEmpty
|
rootView.groupMemberView.groupNameLab.text = viewModel.groupName.isEmpty
|
||||||
? "我的圈子"
|
? "我的圈子"
|
||||||
: viewModel.groupName
|
: viewModel.groupName
|
||||||
MQTTService.shared.updatePhoneReportContext(
|
|
||||||
groupKey: hasRequestedGroup ? requestedGroupKey : "",
|
|
||||||
location: phoneReportLocation
|
|
||||||
)
|
|
||||||
|
|
||||||
let pickerMembers = isSwitchingGroup ? nil : viewModel.memberList
|
let pickerMembers = isSwitchingGroup ? nil : viewModel.memberList
|
||||||
GroupListPopView.updateIfVisible(
|
GroupListPopView.updateIfVisible(
|
||||||
groupModel: model,
|
groupModel: model,
|
||||||
|
|
@ -859,18 +857,6 @@ class HomeViewController: BaseViewController {
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var phoneReportLocation: CLLocation? {
|
|
||||||
if let lastLocation,
|
|
||||||
CLLocationCoordinate2DIsValid(lastLocation.coordinate) {
|
|
||||||
return lastLocation
|
|
||||||
}
|
|
||||||
guard let latitude = Defaults[\.currentLatitude],
|
|
||||||
let longitude = Defaults[\.currentLongitude] else { return nil }
|
|
||||||
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
|
||||||
guard CLLocationCoordinate2DIsValid(coordinate) else { return nil }
|
|
||||||
return CLLocation(latitude: latitude, longitude: longitude)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func phoneUsageCacheKey(groupKey: String, userId: String) -> String {
|
private func phoneUsageCacheKey(groupKey: String, userId: String) -> String {
|
||||||
"\(groupKey)|\(userId)"
|
"\(groupKey)|\(userId)"
|
||||||
}
|
}
|
||||||
|
|
@ -1245,18 +1231,6 @@ class HomeViewController: BaseViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openPhoneReportDetail() {
|
|
||||||
let members = viewModel.memberList.map { $0.toJSON() }
|
|
||||||
var userInfo: [String: Any] = [
|
|
||||||
"members": members,
|
|
||||||
"userId": selectedMemberId
|
|
||||||
]
|
|
||||||
if let group = viewModel.groupModel {
|
|
||||||
userInfo["groupJson"] = group.toJSON()
|
|
||||||
}
|
|
||||||
AppRouter.push(Route.phoneReportDetail, userInfo: userInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openAppUsageReport() {
|
private func openAppUsageReport() {
|
||||||
let members = viewModel.memberList.map { $0.toJSON() }
|
let members = viewModel.memberList.map { $0.toJSON() }
|
||||||
var userInfo: [String: Any] = [
|
var userInfo: [String: Any] = [
|
||||||
|
|
@ -2003,10 +1977,6 @@ extension HomeViewController: MAMapViewDelegate {
|
||||||
// 地图标注
|
// 地图标注
|
||||||
let isFirstFix = lastLocation == nil
|
let isFirstFix = lastLocation == nil
|
||||||
lastLocation = location
|
lastLocation = location
|
||||||
MQTTService.shared.updatePhoneReportContext(
|
|
||||||
groupKey: viewModel.groupModel?.default_group_key,
|
|
||||||
location: location
|
|
||||||
)
|
|
||||||
if isFirstFix {
|
if isFirstFix {
|
||||||
reportCurrentLocationIfNeeded()
|
reportCurrentLocationIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -135,20 +135,23 @@ class HomeViewModel {
|
||||||
/// 标记成员离线(保留 last_active_time 用于显示"X分钟前")
|
/// 标记成员离线(保留 last_active_time 用于显示"X分钟前")
|
||||||
func setMemberOffline(userId: String) {
|
func setMemberOffline(userId: String) {
|
||||||
guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return }
|
guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return }
|
||||||
var updated = memberList
|
guard memberList[idx].is_online else { return }
|
||||||
updated[idx].is_online = false
|
memberList[idx].is_online = false
|
||||||
updateMembers(updated)
|
updateMembers(memberList)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MQTT 位置更新后刷新成员的地址、电量、在线状态
|
/// MQTT 位置更新后刷新成员的地址、电量、在线状态。
|
||||||
|
/// 列表 cell 不展示这些字段,在线状态没变时不要整表 reload。
|
||||||
func updateMemberLocation(userId: String, lat: Double, lon: Double, address: String, battery: String, isOnline: Bool, lastUpdateTime: Int64) {
|
func updateMemberLocation(userId: String, lat: Double, lon: Double, address: String, battery: String, isOnline: Bool, lastUpdateTime: Int64) {
|
||||||
guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return }
|
guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return }
|
||||||
var updated = memberList
|
let onlineChanged = memberList[idx].is_online != isOnline
|
||||||
updated[idx].last_position = "\(lat):\(lon):\(address)"
|
memberList[idx].last_position = "\(lat):\(lon):\(address)"
|
||||||
updated[idx].battery = battery
|
memberList[idx].battery = battery
|
||||||
updated[idx].is_online = isOnline
|
memberList[idx].is_online = isOnline
|
||||||
updated[idx].last_active_time = lastUpdateTime
|
memberList[idx].last_active_time = lastUpdateTime
|
||||||
updateMembers(updated)
|
if onlineChanged {
|
||||||
|
updateMembers(memberList)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 成员是否存在
|
/// 成员是否存在
|
||||||
|
|
|
||||||
|
|
@ -1,338 +0,0 @@
|
||||||
//
|
|
||||||
// MemberPhoneReportView.swift
|
|
||||||
// QuickLocation
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
struct MemberPhoneReportPreview {
|
|
||||||
let screenTimeSeconds: Int?
|
|
||||||
let usageCount: Int?
|
|
||||||
let unlockCount: Int?
|
|
||||||
|
|
||||||
static func currentUser(from manager: UnlockCountManager = .shared) -> MemberPhoneReportPreview {
|
|
||||||
let seconds = manager.todayScreenTimeSeconds
|
|
||||||
return MemberPhoneReportPreview(
|
|
||||||
screenTimeSeconds: seconds > 0 ? seconds : nil,
|
|
||||||
usageCount: manager.todayAppUsageCount,
|
|
||||||
unlockCount: manager.todayCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 成员面板「手机报告」:屏幕使用时长 + 使用次数 + 今日解锁
|
|
||||||
final class MemberPhoneReportView: UIView {
|
|
||||||
|
|
||||||
private enum Layout {
|
|
||||||
static let designCardWidth: CGFloat = 345
|
|
||||||
static let leftPanelWidthRatio: CGFloat = 164 / designCardWidth
|
|
||||||
static let leftPanelAspect: CGFloat = 147 / 164
|
|
||||||
static let panelGap: CGFloat = 13
|
|
||||||
static let cardInset: CGFloat = 12
|
|
||||||
static let usageUnlockGap: CGFloat = 9
|
|
||||||
}
|
|
||||||
|
|
||||||
private let titleColor = UIColor(hexStr: "#293445")
|
|
||||||
private let usageTint = UIColor(hexStr: "#E8FBE8")
|
|
||||||
private let unlockTint = UIColor(hexStr: "#E8F4FF")
|
|
||||||
|
|
||||||
private let sectionIcon = UIImageView()
|
|
||||||
private let sectionTitleLab = UILabel()
|
|
||||||
private let cardView = UIView()
|
|
||||||
private let leftPanel = UIView()
|
|
||||||
private let rightPanel = UIView()
|
|
||||||
private let screenTimeIcon = UIImageView()
|
|
||||||
private let screenTimeTitleLab = UILabel()
|
|
||||||
private let screenTimeBadge = ScreenTimeBadgeView()
|
|
||||||
private let usageCard = UIView()
|
|
||||||
private let usageIcon = UIImageView()
|
|
||||||
private let usageTitleLab = UILabel()
|
|
||||||
private let usageValueLab = UILabel()
|
|
||||||
private let unlockCard = UIView()
|
|
||||||
private let unlockIcon = UIImageView()
|
|
||||||
private let unlockTitleLab = UILabel()
|
|
||||||
private let unlockValueLab = UILabel()
|
|
||||||
private let deniedOverlay = PrivilegeDeniedOverlay()
|
|
||||||
private(set) var isDenied = false
|
|
||||||
var onDeniedAction: (() -> Void)?
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
|
||||||
super.init(frame: frame)
|
|
||||||
setupUI()
|
|
||||||
configure(with: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAccessState(_ state: MemberFeatureAccessState) {
|
|
||||||
isDenied = state == .privilegeDenied
|
|
||||||
deniedOverlay.configure(
|
|
||||||
title: "TA未开启设置",
|
|
||||||
message: "无法查看手机报告",
|
|
||||||
actionTitle: "提醒TA",
|
|
||||||
imageName: "PrivilegeDenied/phone_report"
|
|
||||||
)
|
|
||||||
deniedOverlay.onAction = { [weak self] in self?.onDeniedAction?() }
|
|
||||||
deniedOverlay.setVisible(isDenied)
|
|
||||||
deniedOverlay.layer.cornerRadius = cardView.layer.cornerRadius
|
|
||||||
leftPanel.isHidden = false
|
|
||||||
rightPanel.isHidden = false
|
|
||||||
gestureRecognizers?.forEach { $0.isEnabled = !isDenied }
|
|
||||||
}
|
|
||||||
|
|
||||||
func configure(with preview: MemberPhoneReportPreview?) {
|
|
||||||
if let preview {
|
|
||||||
screenTimeBadge.configure(seconds: preview.screenTimeSeconds)
|
|
||||||
usageValueLab.text = preview.usageCount.map(String.init) ?? "**"
|
|
||||||
unlockValueLab.text = preview.unlockCount.map(String.init) ?? "**"
|
|
||||||
} else {
|
|
||||||
screenTimeBadge.configure(seconds: nil)
|
|
||||||
usageValueLab.text = "**"
|
|
||||||
unlockValueLab.text = "**"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupUI() {
|
|
||||||
backgroundColor = .clear
|
|
||||||
|
|
||||||
sectionIcon.image = UIImage(named: "Home/member_phone_report")
|
|
||||||
sectionIcon.contentMode = .scaleAspectFit
|
|
||||||
addSubview(sectionIcon)
|
|
||||||
sectionIcon.layoutChain
|
|
||||||
.top()
|
|
||||||
.left(15)
|
|
||||||
.width(18)
|
|
||||||
.heightToWidth(1)
|
|
||||||
|
|
||||||
sectionTitleLab.text = "手机报告"
|
|
||||||
sectionTitleLab.font = .systemFont(ofSize: 16, weight: .bold)
|
|
||||||
sectionTitleLab.textColor = titleColor
|
|
||||||
addSubview(sectionTitleLab)
|
|
||||||
sectionTitleLab.layoutChain
|
|
||||||
.leftToRightOfView(sectionIcon, offset: 6)
|
|
||||||
.centerY(sectionIcon)
|
|
||||||
|
|
||||||
cardView.backgroundColor = .white
|
|
||||||
cardView.cornerRadius = 16
|
|
||||||
addSubview(cardView)
|
|
||||||
cardView.layoutChain
|
|
||||||
.topToBottomOfView(sectionIcon, offset: 12)
|
|
||||||
.edgesHorzontal(15)
|
|
||||||
.bottom()
|
|
||||||
|
|
||||||
cardView.addSubview(deniedOverlay)
|
|
||||||
deniedOverlay.layoutChain.edges()
|
|
||||||
|
|
||||||
cardView.addSubview(leftPanel)
|
|
||||||
cardView.addSubview(rightPanel)
|
|
||||||
leftPanel.backgroundColor = UIColor(hexStr: "#FFFCEA")
|
|
||||||
leftPanel.cornerRadius = 14
|
|
||||||
rightPanel.backgroundColor = .clear
|
|
||||||
leftPanel.layoutChain
|
|
||||||
.top(Layout.cardInset)
|
|
||||||
.left(Layout.cardInset)
|
|
||||||
.widthToView(cardView, multiplier: Layout.leftPanelWidthRatio)
|
|
||||||
.heightToWidth(Layout.leftPanelAspect)
|
|
||||||
|
|
||||||
rightPanel.layoutChain
|
|
||||||
.top(Layout.cardInset)
|
|
||||||
.right(Layout.cardInset)
|
|
||||||
.leftToRightOfView(leftPanel, offset: Layout.panelGap)
|
|
||||||
.heightToView(leftPanel)
|
|
||||||
.bottom(Layout.cardInset)
|
|
||||||
|
|
||||||
screenTimeIcon.image = UIImage(named: "Home/member_report_clock")
|
|
||||||
screenTimeIcon.contentMode = .scaleAspectFit
|
|
||||||
leftPanel.addSubview(screenTimeIcon)
|
|
||||||
screenTimeIcon.layoutChain
|
|
||||||
.top(7)
|
|
||||||
.left(7)
|
|
||||||
.width(20)
|
|
||||||
.height(20)
|
|
||||||
|
|
||||||
screenTimeTitleLab.text = "屏幕使用时长"
|
|
||||||
screenTimeTitleLab.font = .systemFont(ofSize: 12, weight: .bold)
|
|
||||||
screenTimeTitleLab.textColor = UIColor(hexStr: "#293445")
|
|
||||||
leftPanel.addSubview(screenTimeTitleLab)
|
|
||||||
screenTimeTitleLab.layoutChain
|
|
||||||
.leftToRightOfView(screenTimeIcon, offset: 6)
|
|
||||||
.centerY(screenTimeIcon)
|
|
||||||
|
|
||||||
leftPanel.addSubview(screenTimeBadge)
|
|
||||||
screenTimeBadge.layoutChain
|
|
||||||
.topToBottomOfView(screenTimeTitleLab, offset: 13)
|
|
||||||
.centerX(leftPanel, offset: 7)
|
|
||||||
|
|
||||||
let screenTimeBadgeArrow = UIImageView(image: UIImage(named: "Home/member_screenTime_arrow"))
|
|
||||||
leftPanel.addSubview(screenTimeBadgeArrow)
|
|
||||||
screenTimeBadgeArrow.layoutChain
|
|
||||||
.topToBottomOfView(screenTimeBadge)
|
|
||||||
.centerX(screenTimeBadge)
|
|
||||||
|
|
||||||
let barchartIcon = UIImageView(image: UIImage(named: "Home/member_screenTime"))
|
|
||||||
leftPanel.addSubview(barchartIcon)
|
|
||||||
barchartIcon.layoutChain
|
|
||||||
.centerX()
|
|
||||||
.topToBottomOfView(screenTimeBadge, offset: 12)
|
|
||||||
|
|
||||||
usageCard.backgroundColor = UIColor(hexStr: "#F8FFEE")
|
|
||||||
usageCard.cornerRadius = 14
|
|
||||||
rightPanel.addSubview(usageCard)
|
|
||||||
usageCard.layoutChain
|
|
||||||
.top()
|
|
||||||
.edgesHorzontal()
|
|
||||||
|
|
||||||
usageIcon.image = UIImage(named: "Home/member_usage_count")
|
|
||||||
usageIcon.contentMode = .scaleAspectFit
|
|
||||||
usageCard.addSubview(usageIcon)
|
|
||||||
usageIcon.layoutChain
|
|
||||||
.top(8)
|
|
||||||
.left(10)
|
|
||||||
.width(20)
|
|
||||||
.height(20)
|
|
||||||
|
|
||||||
usageTitleLab.text = "使用次数"
|
|
||||||
usageTitleLab.font = .systemFont(ofSize: 12, weight: .bold)
|
|
||||||
usageTitleLab.textColor = UIColor(hexStr: "#293445")
|
|
||||||
usageCard.addSubview(usageTitleLab)
|
|
||||||
usageTitleLab.layoutChain
|
|
||||||
.leftToRightOfView(usageIcon, offset: 6)
|
|
||||||
.centerY(usageIcon)
|
|
||||||
.right(relation: .lessThanOrEqual)
|
|
||||||
|
|
||||||
usageValueLab.font = .systemFont(ofSize: 24, weight: .bold)
|
|
||||||
usageValueLab.textColor = UIColor(hexStr: "#83DE00")
|
|
||||||
usageCard.addSubview(usageValueLab)
|
|
||||||
usageValueLab.layoutChain
|
|
||||||
.topToBottomOfView(usageTitleLab, offset: 4)
|
|
||||||
.centerX()
|
|
||||||
|
|
||||||
let usageValueUnitLab = UILabel()
|
|
||||||
usageValueUnitLab.text = "次"
|
|
||||||
usageValueUnitLab.textColor = UIColor(hexStr: "#293445")
|
|
||||||
usageValueUnitLab.font = .systemFont(ofSize: 12, weight: .bold)
|
|
||||||
usageCard.addSubview(usageValueUnitLab)
|
|
||||||
usageValueUnitLab.layoutChain
|
|
||||||
.leftToRightOfView(usageValueLab, offset: 3)
|
|
||||||
.bottomToView(usageValueLab, offset: -4)
|
|
||||||
|
|
||||||
unlockCard.backgroundColor = UIColor(hexStr: "#F2FEFF")
|
|
||||||
unlockCard.cornerRadius = 14
|
|
||||||
rightPanel.addSubview(unlockCard)
|
|
||||||
unlockCard.layoutChain
|
|
||||||
.topToBottomOfView(usageCard, offset: Layout.usageUnlockGap)
|
|
||||||
.edgesHorzontal()
|
|
||||||
.heightToView(usageCard)
|
|
||||||
.bottom()
|
|
||||||
|
|
||||||
unlockIcon.image = UIImage(named: "Home/member_daily_unlock")
|
|
||||||
unlockIcon.contentMode = .scaleAspectFit
|
|
||||||
unlockCard.addSubview(unlockIcon)
|
|
||||||
unlockIcon.layoutChain
|
|
||||||
.top(8)
|
|
||||||
.left(10)
|
|
||||||
.width(20)
|
|
||||||
.height(20)
|
|
||||||
|
|
||||||
unlockTitleLab.text = "今日解锁"
|
|
||||||
unlockTitleLab.font = .systemFont(ofSize: 12, weight: .bold)
|
|
||||||
unlockTitleLab.textColor = UIColor(hexStr: "#293445")
|
|
||||||
unlockCard.addSubview(unlockTitleLab)
|
|
||||||
unlockTitleLab.layoutChain
|
|
||||||
.leftToRightOfView(unlockIcon, offset: 6)
|
|
||||||
.centerY(unlockIcon)
|
|
||||||
.right(relation: .lessThanOrEqual)
|
|
||||||
|
|
||||||
unlockValueLab.font = .systemFont(ofSize: 24, weight: .bold)
|
|
||||||
unlockValueLab.textColor = UIColor(hexStr: "#08B6C4")
|
|
||||||
unlockCard.addSubview(unlockValueLab)
|
|
||||||
unlockValueLab.layoutChain
|
|
||||||
.topToBottomOfView(unlockTitleLab, offset: 4)
|
|
||||||
.centerX()
|
|
||||||
|
|
||||||
let unlockValueUnitLab = UILabel()
|
|
||||||
unlockValueUnitLab.text = "次"
|
|
||||||
unlockValueUnitLab.textColor = UIColor(hexStr: "#293445")
|
|
||||||
unlockValueUnitLab.font = .systemFont(ofSize: 12, weight: .bold)
|
|
||||||
unlockCard.addSubview(unlockValueUnitLab)
|
|
||||||
unlockValueUnitLab.layoutChain
|
|
||||||
.leftToRightOfView(unlockValueLab, offset: 3)
|
|
||||||
.bottomToView(unlockValueLab, offset: -4)
|
|
||||||
|
|
||||||
cardView.bringSubviewToFront(deniedOverlay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 屏幕时长 Badge
|
|
||||||
|
|
||||||
private final class ScreenTimeBadgeView: UIView {
|
|
||||||
|
|
||||||
private let contentStack = UIStackView()
|
|
||||||
private let numberColor = UIColor(hexStr: "#FF6B4A")
|
|
||||||
private let unitColor = UIColor(hexStr: "#293445")
|
|
||||||
private let numberFont = UIFont.systemFont(ofSize: 24, weight: .heavy)
|
|
||||||
private let unitFont = UIFont.systemFont(ofSize: 14, weight: .heavy)
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
|
||||||
super.init(frame: frame)
|
|
||||||
backgroundColor = .white
|
|
||||||
cornerRadius = 11
|
|
||||||
contentStack.axis = .horizontal
|
|
||||||
contentStack.alignment = .lastBaseline
|
|
||||||
contentStack.spacing = 2
|
|
||||||
addSubview(contentStack)
|
|
||||||
contentStack.layoutChain
|
|
||||||
.edges(UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
func configure(seconds: Int?) {
|
|
||||||
contentStack.arrangedSubviews.forEach { view in
|
|
||||||
contentStack.removeArrangedSubview(view)
|
|
||||||
view.removeFromSuperview()
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let seconds, seconds >= 0 else {
|
|
||||||
contentStack.addArrangedSubview(makeLabel(text: "**", font: numberFont, color: numberColor))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let hours = seconds / 3600
|
|
||||||
let minutes = (seconds % 3600) / 60
|
|
||||||
|
|
||||||
if hours > 0 {
|
|
||||||
appendPart(number: "\(hours)", unit: "h")
|
|
||||||
if minutes > 0 {
|
|
||||||
contentStack.addArrangedSubview(makeSpacer(width: 4))
|
|
||||||
appendPart(number: "\(minutes)", unit: "m")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
appendPart(number: "\(minutes)", unit: "m")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func appendPart(number: String, unit: String) {
|
|
||||||
contentStack.addArrangedSubview(makeLabel(text: number, font: numberFont, color: numberColor))
|
|
||||||
contentStack.addArrangedSubview(makeLabel(text: unit, font: unitFont, color: unitColor))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeLabel(text: String, font: UIFont, color: UIColor) -> UILabel {
|
|
||||||
let label = UILabel()
|
|
||||||
label.text = text
|
|
||||||
label.font = font
|
|
||||||
label.textColor = color
|
|
||||||
return label
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeSpacer(width: CGFloat) -> UIView {
|
|
||||||
let spacer = UIView()
|
|
||||||
spacer.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
spacer.widthAnchor.constraint(equalToConstant: width).isActive = true
|
|
||||||
return spacer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
//
|
|
||||||
// PhoneReportDetailVC.swift
|
|
||||||
// QuickLocation
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import RxSwift
|
|
||||||
import RxCocoa
|
|
||||||
import ObjectMapper
|
|
||||||
|
|
||||||
final class PhoneReportDetailVC: BaseViewController {
|
|
||||||
|
|
||||||
private var rootView: PhoneReportDetailView!
|
|
||||||
private let viewModel: PhoneReportDetailViewModel
|
|
||||||
private var groupModel: GroupModel?
|
|
||||||
|
|
||||||
init(members: [GroupMemberModel], selectedUserId: String, groupModel: GroupModel?) {
|
|
||||||
self.viewModel = PhoneReportDetailViewModel(
|
|
||||||
members: members,
|
|
||||||
selectedUserId: selectedUserId,
|
|
||||||
groupKey: groupModel?.default_group_key ?? ""
|
|
||||||
)
|
|
||||||
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 = PhoneReportDetailView(frame: UIScreen.main.bounds)
|
|
||||||
view = rootView
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
rootView.memberCV.dataSource = self
|
|
||||||
rootView.memberCV.delegate = self
|
|
||||||
bind()
|
|
||||||
rootView.memberCV.reloadData()
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.rootView.updateMemberFadeVisibility()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
viewModel.selectedMemberId
|
|
||||||
.observe(on: MainScheduler.instance)
|
|
||||||
.subscribe(onNext: { [weak self] _ in
|
|
||||||
self?.rootView.memberCV.reloadData()
|
|
||||||
})
|
|
||||||
.disposed(by: disposeBag)
|
|
||||||
|
|
||||||
viewModel.snapshot
|
|
||||||
.observe(on: MainScheduler.instance)
|
|
||||||
.subscribe(onNext: { [weak self] snap in
|
|
||||||
self?.rootView.apply(snap)
|
|
||||||
})
|
|
||||||
.disposed(by: disposeBag)
|
|
||||||
|
|
||||||
viewModel.selectedMemberId
|
|
||||||
.observe(on: MainScheduler.instance)
|
|
||||||
.subscribe(onNext: { [weak self] _ in
|
|
||||||
self?.updatePrivilegeOverlay()
|
|
||||||
})
|
|
||||||
.disposed(by: disposeBag)
|
|
||||||
|
|
||||||
rootView.onRemind = { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
GroupService.sendPrivilegeRemind(
|
|
||||||
content: "请开启手机报告隐私设置",
|
|
||||||
groupKey: self.groupModel?.default_group_key ?? "",
|
|
||||||
userId: self.viewModel.selectedMemberId.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
updatePrivilegeOverlay()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updatePrivilegeOverlay() {
|
|
||||||
let userId = viewModel.selectedMemberId.value
|
|
||||||
let isSelf = userId == AppContextManager.shared.userId
|
|
||||||
let member = viewModel.members.first { $0.user_id == userId }
|
|
||||||
let denied = !isSelf && member?.extra.privilege.phone_report == false
|
|
||||||
rootView.setPrivilegeDenied(denied)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func switchGroup() {
|
|
||||||
guard let groupModel else { return }
|
|
||||||
GroupListPopView.show(groupModel: groupModel,
|
|
||||||
currentMembers: viewModel.members) { [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
|
|
||||||
self?.navigationController?.popViewController(animated: true)
|
|
||||||
})
|
|
||||||
.disposed(by: self.disposeBag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PhoneReportDetailVC: UICollectionViewDataSource, UICollectionViewDelegate {
|
|
||||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
|
||||||
viewModel.members.count
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
|
||||||
let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath)
|
|
||||||
let model = viewModel.members[indexPath.item]
|
|
||||||
let selected = model.user_id == viewModel.selectedMemberId.value
|
|
||||||
let isMe = model.user_id == AppContextManager.shared.userId
|
|
||||||
cell.configure(model: model, isCurrentUser: isMe, isSelected: selected)
|
|
||||||
return cell
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
|
||||||
let model = viewModel.members[indexPath.item]
|
|
||||||
viewModel.selectedMemberId.accept(model.user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
||||||
guard scrollView === rootView.memberCV else { return }
|
|
||||||
rootView.updateMemberFadeVisibility()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,747 +0,0 @@
|
||||||
//
|
|
||||||
// PhoneReportDetailView.swift
|
|
||||||
// QuickLocation
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import Kingfisher
|
|
||||||
|
|
||||||
final class PhoneReportDetailView: UIView {
|
|
||||||
|
|
||||||
lazy var navView: BaseNavigationView = {
|
|
||||||
let nav = BaseNavigationView(title: "手机报告")
|
|
||||||
return nav
|
|
||||||
}()
|
|
||||||
|
|
||||||
lazy var navBgView: UIImageView = {
|
|
||||||
let iv = UIImageView()
|
|
||||||
iv.image = UIImage(named: "Common/navBar_bg_2")
|
|
||||||
iv.contentMode = .scaleAspectFill
|
|
||||||
return iv
|
|
||||||
}()
|
|
||||||
|
|
||||||
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
|
|
||||||
btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 20, bottom: 15, right: 18)
|
|
||||||
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 = .clear
|
|
||||||
cv.showsHorizontalScrollIndicator = false
|
|
||||||
cv.contentInset = UIEdgeInsets(top: 5, left: 5, bottom: 0, right: 5)
|
|
||||||
cv.register(GroupMemberListCell.self)
|
|
||||||
// cv.applyPhoneReportCardStyle(cornerRadius: 20)
|
|
||||||
return cv
|
|
||||||
}()
|
|
||||||
|
|
||||||
let scrollView = UIScrollView()
|
|
||||||
let contentView = UIView()
|
|
||||||
private let memberTrailingFadeView = PhoneReportMemberTrailingFadeView()
|
|
||||||
|
|
||||||
// MARK: Status
|
|
||||||
let statusTitleIcon = UIImageView(image: UIImage(named: "Home/report_phone"))
|
|
||||||
let statusTitleLab = UILabel()
|
|
||||||
let statusCard = UIView()
|
|
||||||
let batteryRing = PhoneBatteryRingView()
|
|
||||||
let estimateLab = UILabel()
|
|
||||||
let brightnessCard = PhoneStatusMiniCard(iconName: "Home/phone_brightness", title: "亮度")
|
|
||||||
let volumeCard = PhoneStatusMiniCard(iconName: "Home/phone_volume", title: "音量")
|
|
||||||
let wifiCard = PhoneStatusMiniCard(iconName: "Home/phone_wifi", title: "WIFI")
|
|
||||||
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"))
|
|
||||||
let brandLogoImage = UIImageView()
|
|
||||||
private var brandLogoURL = ""
|
|
||||||
private let statusDeniedOverlay = PrivilegeDeniedOverlay()
|
|
||||||
var onRemind: (() -> Void)?
|
|
||||||
|
|
||||||
// MARK: Charts
|
|
||||||
let screenSection = PhoneReportChartSectionView(
|
|
||||||
title: "屏幕使用时间",
|
|
||||||
iconName: "Home/report_screen",
|
|
||||||
summaryPrefix: "今日使用时长",
|
|
||||||
summaryColor: UIColor(hexStr: "#00ADFE"),
|
|
||||||
barGradient: (UIColor(hexStr: "#C0EAFF"), UIColor(hexStr: "#ECF9FF")),
|
|
||||||
highlightGradient: (UIColor(hexStr: "#16B3FF"), UIColor(hexStr: "#9EDFFF")),
|
|
||||||
tipColor: UIColor(hexStr: "#20B6FF"),
|
|
||||||
tipArrowImageName: "Home/report_screen_tip_arrow"
|
|
||||||
)
|
|
||||||
let unlockSection = PhoneReportChartSectionView(
|
|
||||||
title: "APP使用次数",
|
|
||||||
iconName: "Home/report_usage",
|
|
||||||
summaryPrefix: "今日使用次数",
|
|
||||||
summaryColor: UIColor(hexStr: "#68C025"),
|
|
||||||
barGradient: (UIColor(hexStr: "#DDFFA1"), UIColor(hexStr: "#F2FFDC")),
|
|
||||||
highlightGradient: (UIColor(hexStr: "#AAF52A"), UIColor(hexStr: "#E8FFC1")),
|
|
||||||
tipColor: UIColor(hexStr: "#86E73D"),
|
|
||||||
tipArrowImageName: "Home/report_usage_tip_arrow",
|
|
||||||
valueSuffix: "次"
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
// navView.addRightButton(switchGroupBtn)
|
|
||||||
|
|
||||||
addSubview(scrollView)
|
|
||||||
scrollView.showsVerticalScrollIndicator = false
|
|
||||||
scrollView.clipsToBounds = true
|
|
||||||
scrollView.delaysContentTouches = false
|
|
||||||
scrollView.canCancelContentTouches = true
|
|
||||||
scrollView.layoutChain
|
|
||||||
.topToBottomOfView(navView)
|
|
||||||
.edgesHorzontal()
|
|
||||||
.bottom()
|
|
||||||
scrollView.addSubview(contentView)
|
|
||||||
contentView.layoutChain.edges().widthToView(scrollView)
|
|
||||||
|
|
||||||
let memberView = UIView()
|
|
||||||
memberView.backgroundColor = .white
|
|
||||||
memberView.applyPhoneReportCardStyle(cornerRadius: 20)
|
|
||||||
|
|
||||||
contentView.addSubview(memberView)
|
|
||||||
memberView.addSubview(memberCV)
|
|
||||||
memberView.addSubview(memberTrailingFadeView)
|
|
||||||
|
|
||||||
memberView.layoutChain
|
|
||||||
.top(8)
|
|
||||||
.edgesHorzontal(15)
|
|
||||||
.height(100)
|
|
||||||
|
|
||||||
memberCV.layoutChain
|
|
||||||
.edgesVertical()
|
|
||||||
.edgesHorzontal()
|
|
||||||
|
|
||||||
memberTrailingFadeView.layoutChain
|
|
||||||
.top()
|
|
||||||
.right()
|
|
||||||
.bottom()
|
|
||||||
.width(52)
|
|
||||||
|
|
||||||
statusTitleIcon.contentMode = .scaleAspectFit
|
|
||||||
contentView.addSubview(statusTitleIcon)
|
|
||||||
statusTitleIcon.layoutChain
|
|
||||||
.topToBottomOfView(memberCV, offset: 12)
|
|
||||||
.left(15)
|
|
||||||
.width(18)
|
|
||||||
.height(18)
|
|
||||||
|
|
||||||
statusTitleLab.text = "手机状态"
|
|
||||||
statusTitleLab.font = .systemFont(ofSize: 16, weight: .bold)
|
|
||||||
statusTitleLab.textColor = UIColor(hexStr: "#293445")
|
|
||||||
contentView.addSubview(statusTitleLab)
|
|
||||||
statusTitleLab.layoutChain
|
|
||||||
.centerY(statusTitleIcon)
|
|
||||||
.leftToRightOfView(statusTitleIcon, offset: 6)
|
|
||||||
|
|
||||||
statusCard.backgroundColor = .white
|
|
||||||
statusCard.applyPhoneReportCardStyle(cornerRadius: 20)
|
|
||||||
contentView.addSubview(statusCard)
|
|
||||||
statusCard.layoutChain
|
|
||||||
.topToBottomOfView(statusTitleIcon, offset: 10)
|
|
||||||
.edgesHorzontal(15)
|
|
||||||
.height(175)
|
|
||||||
statusCard.addSubview(statusDeniedOverlay)
|
|
||||||
statusDeniedOverlay.layoutChain.edges()
|
|
||||||
statusDeniedOverlay.layer.cornerRadius = 20
|
|
||||||
|
|
||||||
let leftPanel = UIView()
|
|
||||||
leftPanel.backgroundColor = .clear
|
|
||||||
leftPanel.cornerRadius = 14
|
|
||||||
statusCard.addSubview(leftPanel)
|
|
||||||
leftPanel.layoutChain
|
|
||||||
.edges(excludingEdge: .right)
|
|
||||||
.width(136)
|
|
||||||
|
|
||||||
leftPanel.addSubview(batteryRing)
|
|
||||||
batteryRing.layoutChain.top(23).centerX().width(100).height(100)
|
|
||||||
|
|
||||||
estimateLab.font = .systemFont(ofSize: 12, weight: .medium)
|
|
||||||
estimateLab.textColor = UIColor(hexStr: "#293445")
|
|
||||||
estimateLab.textAlignment = .center
|
|
||||||
estimateLab.backgroundColor = UIColor(hexStr: "#FAFAFA")
|
|
||||||
estimateLab.cornerRadius = 5
|
|
||||||
leftPanel.addSubview(estimateLab)
|
|
||||||
estimateLab.layoutChain
|
|
||||||
.topToBottomOfView(batteryRing, offset: 12)
|
|
||||||
.centerX()
|
|
||||||
.height(22)
|
|
||||||
.width(120)
|
|
||||||
|
|
||||||
let rightStack = UIStackView(arrangedSubviews: [brightnessCard, volumeCard, wifiCard])
|
|
||||||
rightStack.axis = .horizontal
|
|
||||||
rightStack.distribution = .fillEqually
|
|
||||||
rightStack.spacing = 8
|
|
||||||
statusCard.addSubview(rightStack)
|
|
||||||
rightStack.layoutChain
|
|
||||||
.top(12)
|
|
||||||
.leftToRightOfView(leftPanel, offset: 10)
|
|
||||||
.right(12)
|
|
||||||
.height(64)
|
|
||||||
|
|
||||||
deviceCard.backgroundColor = UIColor(hexStr: "#FAFAFA")
|
|
||||||
deviceCard.cornerRadius = 12
|
|
||||||
statusCard.addSubview(deviceCard)
|
|
||||||
deviceCard.layoutChain
|
|
||||||
.topToBottomOfView(rightStack, offset: 8)
|
|
||||||
.leftToView(rightStack)
|
|
||||||
.right(12)
|
|
||||||
.bottom(12)
|
|
||||||
|
|
||||||
deviceNameLab.font = .systemFont(ofSize: 13, weight: .bold)
|
|
||||||
deviceNameLab.textColor = UIColor(hexStr: "#293445")
|
|
||||||
deviceCard.addSubview(deviceNameLab)
|
|
||||||
deviceNameLab.layoutChain.top(10).left(10).right(60)
|
|
||||||
|
|
||||||
storageLab.font = .systemFont(ofSize: 11, weight: .medium)
|
|
||||||
storageLab.textColor = UIColor(hexStr: "#6B7280")
|
|
||||||
deviceCard.addSubview(storageLab)
|
|
||||||
storageLab.layoutChain.topToBottomOfView(deviceNameLab, offset: 4).left(10)
|
|
||||||
|
|
||||||
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(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)
|
|
||||||
|
|
||||||
brandLogoImage.contentMode = .scaleAspectFit
|
|
||||||
brandLogoImage.isHidden = true
|
|
||||||
deviceImage.addSubview(brandLogoImage)
|
|
||||||
brandLogoImage.layoutChain.center().width(18).height(18)
|
|
||||||
|
|
||||||
contentView.addSubview(screenSection)
|
|
||||||
screenSection.layoutChain
|
|
||||||
.topToBottomOfView(statusCard, offset: 16)
|
|
||||||
.edgesHorzontal(15)
|
|
||||||
|
|
||||||
contentView.addSubview(unlockSection)
|
|
||||||
unlockSection.layoutChain
|
|
||||||
.topToBottomOfView(screenSection, offset: 16)
|
|
||||||
.edgesHorzontal(15)
|
|
||||||
.bottom(24)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
updateMemberFadeVisibility()
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateMemberFadeVisibility() {
|
|
||||||
let rightInset = memberCV.adjustedContentInset.right
|
|
||||||
let maxOffsetX = max(
|
|
||||||
-memberCV.adjustedContentInset.left,
|
|
||||||
memberCV.contentSize.width - memberCV.bounds.width + rightInset
|
|
||||||
)
|
|
||||||
memberTrailingFadeView.isHidden = memberCV.contentOffset.x >= maxOffsetX - 1
|
|
||||||
|| memberCV.contentSize.width <= memberCV.bounds.width
|
|
||||||
}
|
|
||||||
|
|
||||||
func apply(_ snap: PhoneReportDetailViewModel.StatusSnapshot) {
|
|
||||||
batteryRing.configure(percent: snap.batteryPercent, text: snap.batteryText, isCharging: snap.isCharging)
|
|
||||||
estimateLab.text = "预计使用\(snap.estimateText)"
|
|
||||||
brightnessCard.setValue(snap.brightnessText)
|
|
||||||
volumeCard.setValue(snap.volumeText)
|
|
||||||
wifiCard.setValue(snap.wifiText)
|
|
||||||
deviceNameLab.text = snap.deviceText
|
|
||||||
storageLab.text = snap.storageText
|
|
||||||
storageBar.progress = Float(snap.storageRatio)
|
|
||||||
updateBrandLogo(urlString: snap.brandIconURL)
|
|
||||||
screenSection.setSummary(snap.screenTodayText)
|
|
||||||
unlockSection.setSummary(snap.appUsageTodayText)
|
|
||||||
screenSection.setBars(snap.screenBars)
|
|
||||||
unlockSection.setBars(snap.appUsageBars)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setPrivilegeDenied(_ denied: Bool) {
|
|
||||||
let title = "TA未开启设置"
|
|
||||||
let message = "无法查看手机状态"
|
|
||||||
let action = "提醒TA"
|
|
||||||
statusDeniedOverlay.configure(
|
|
||||||
title: title,
|
|
||||||
message: message,
|
|
||||||
actionTitle: action,
|
|
||||||
imageName: "PrivilegeDenied/report_status"
|
|
||||||
)
|
|
||||||
statusDeniedOverlay.onAction = { [weak self] in self?.onRemind?() }
|
|
||||||
statusDeniedOverlay.setVisible(denied)
|
|
||||||
if denied { statusCard.bringSubviewToFront(statusDeniedOverlay) }
|
|
||||||
screenSection.setDenied(denied, imageName: "PrivilegeDenied/report_screen", onRemind: { [weak self] in
|
|
||||||
self?.onRemind?()
|
|
||||||
})
|
|
||||||
unlockSection.setDenied(denied, imageName: "PrivilegeDenied/report_usage", onRemind: { [weak self] in
|
|
||||||
self?.onRemind?()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateBrandLogo(urlString: String) {
|
|
||||||
brandLogoURL = urlString
|
|
||||||
brandLogoImage.kf.cancelDownloadTask()
|
|
||||||
brandLogoImage.image = nil
|
|
||||||
brandLogoImage.isHidden = true
|
|
||||||
|
|
||||||
guard let url = URL(string: urlString), !urlString.isEmpty else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
brandLogoImage.kf.setImage(with: url) { [weak self] result in
|
|
||||||
guard let self, self.brandLogoURL == urlString else { return }
|
|
||||||
switch result {
|
|
||||||
case .success:
|
|
||||||
self.brandLogoImage.isHidden = false
|
|
||||||
case .failure:
|
|
||||||
self.brandLogoImage.image = nil
|
|
||||||
self.brandLogoImage.isHidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension UIView {
|
|
||||||
func applyPhoneReportCardStyle(cornerRadius: CGFloat) {
|
|
||||||
layer.cornerRadius = cornerRadius
|
|
||||||
clipsToBounds = false
|
|
||||||
layer.masksToBounds = false
|
|
||||||
layer.shadowColor = UIColor(hexStr: "#E5E5E5", alpha: 0.3).cgColor
|
|
||||||
layer.shadowOffset = CGSize(width: 0, height: 0)
|
|
||||||
layer.shadowOpacity = 1
|
|
||||||
layer.shadowRadius = 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Subviews
|
|
||||||
|
|
||||||
final class PhoneBatteryRingView: UIView {
|
|
||||||
private let track = CAShapeLayer()
|
|
||||||
private let progress = CAShapeLayer()
|
|
||||||
private let valueLab = UILabel()
|
|
||||||
private let bolt = UIImageView(image: UIImage(named: "Home/report_bolt"))
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
|
||||||
super.init(frame: frame)
|
|
||||||
track.fillColor = UIColor.clear.cgColor
|
|
||||||
track.strokeColor = UIColor(hexStr: "#DEFFD8").cgColor
|
|
||||||
track.lineWidth = 11
|
|
||||||
layer.addSublayer(track)
|
|
||||||
progress.fillColor = UIColor.clear.cgColor
|
|
||||||
progress.strokeColor = UIColor(hexStr: "#27DB4B").cgColor
|
|
||||||
progress.lineWidth = 11
|
|
||||||
progress.lineCap = .round
|
|
||||||
progress.strokeEnd = 0
|
|
||||||
layer.addSublayer(progress)
|
|
||||||
|
|
||||||
bolt.contentMode = .scaleAspectFit
|
|
||||||
addSubview(bolt)
|
|
||||||
valueLab.font = .systemFont(ofSize: 16, weight: .heavy)
|
|
||||||
valueLab.textColor = UIColor(hexStr: "#293445")
|
|
||||||
valueLab.textAlignment = .center
|
|
||||||
addSubview(valueLab)
|
|
||||||
valueLab.layoutChain.centerX().centerY()
|
|
||||||
bolt.layoutChain
|
|
||||||
.centerY(valueLab)
|
|
||||||
.rightToLeftOfView(valueLab, offset: -2)
|
|
||||||
.width(12)
|
|
||||||
.height(14)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
||||||
|
|
||||||
override func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
|
||||||
let radius = min(bounds.width, bounds.height) / 2 - 6
|
|
||||||
let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: -.pi / 2, endAngle: 1.5 * .pi, clockwise: true)
|
|
||||||
track.path = path.cgPath
|
|
||||||
progress.path = path.cgPath
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class PhoneStatusMiniCard: UIView {
|
|
||||||
private let icon = UIImageView()
|
|
||||||
private let titleLab = UILabel()
|
|
||||||
private let valueLab = UILabel()
|
|
||||||
|
|
||||||
init(iconName: String, title: String) {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
backgroundColor = UIColor(hexStr: "#FAFAFA")
|
|
||||||
cornerRadius = 12
|
|
||||||
icon.image = UIImage(named: iconName)
|
|
||||||
icon.contentMode = .scaleAspectFit
|
|
||||||
addSubview(icon)
|
|
||||||
icon.layoutChain.top(7).left(5).width(16).height(16)
|
|
||||||
titleLab.text = title
|
|
||||||
titleLab.font = .systemFont(ofSize: 10, weight: .medium)
|
|
||||||
titleLab.textColor = UIColor(hexStr: "#AAAAAA")
|
|
||||||
titleLab.textAlignment = .center
|
|
||||||
addSubview(titleLab)
|
|
||||||
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: 10).edgesHorzontal(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
||||||
|
|
||||||
func setValue(_ text: String) {
|
|
||||||
valueLab.text = text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class PhoneReportChartSectionView: UIView {
|
|
||||||
let summaryPrefix: String
|
|
||||||
private let titleLab = UILabel()
|
|
||||||
private let summaryPrefixLab = UILabel()
|
|
||||||
private let summaryLab = UILabel()
|
|
||||||
private let chart = PhoneReportBarChartView()
|
|
||||||
private let summaryColor: UIColor
|
|
||||||
private let barGradient: (UIColor, UIColor)
|
|
||||||
private let highlightGradient: (UIColor, UIColor)
|
|
||||||
private let tipColor: UIColor
|
|
||||||
private let tipArrowImageName: String
|
|
||||||
private let valueSuffix: String
|
|
||||||
private let card = UIView()
|
|
||||||
private let deniedOverlay = PrivilegeDeniedOverlay()
|
|
||||||
|
|
||||||
init(
|
|
||||||
title: String,
|
|
||||||
iconName: String,
|
|
||||||
summaryPrefix: String,
|
|
||||||
summaryColor: UIColor,
|
|
||||||
barGradient: (UIColor, UIColor),
|
|
||||||
highlightGradient: (UIColor, UIColor),
|
|
||||||
tipColor: UIColor,
|
|
||||||
tipArrowImageName: String,
|
|
||||||
valueSuffix: String = ""
|
|
||||||
) {
|
|
||||||
self.summaryPrefix = summaryPrefix
|
|
||||||
self.summaryColor = summaryColor
|
|
||||||
self.barGradient = barGradient
|
|
||||||
self.highlightGradient = highlightGradient
|
|
||||||
self.tipColor = tipColor
|
|
||||||
self.tipArrowImageName = tipArrowImageName
|
|
||||||
self.valueSuffix = valueSuffix
|
|
||||||
super.init(frame: .zero)
|
|
||||||
let titleIcon = UIImageView(image: UIImage(named: iconName))
|
|
||||||
titleIcon.contentMode = .scaleAspectFit
|
|
||||||
addSubview(titleIcon)
|
|
||||||
titleIcon.layoutChain.top().left().width(18).height(18)
|
|
||||||
titleLab.text = title
|
|
||||||
titleLab.font = .systemFont(ofSize: 16, weight: .bold)
|
|
||||||
titleLab.textColor = UIColor(hexStr: "#293445")
|
|
||||||
addSubview(titleLab)
|
|
||||||
titleLab.layoutChain.centerY(titleIcon).leftToRightOfView(titleIcon, offset: 6)
|
|
||||||
|
|
||||||
card.backgroundColor = .white
|
|
||||||
card.applyPhoneReportCardStyle(cornerRadius: 20)
|
|
||||||
addSubview(card)
|
|
||||||
card.layoutChain.topToBottomOfView(titleLab, offset: 10).edgesHorzontal().bottom().height(180)
|
|
||||||
card.addSubview(deniedOverlay)
|
|
||||||
deniedOverlay.layoutChain.edges()
|
|
||||||
deniedOverlay.layer.cornerRadius = 20
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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)
|
|
||||||
.right(14, relation: .greaterThanOrEqual)
|
|
||||||
|
|
||||||
card.addSubview(chart)
|
|
||||||
chart.layoutChain.topToBottomOfView(summaryPrefixLab, offset: 16).edgesHorzontal(25).bottom(14)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
||||||
|
|
||||||
func setDenied(_ denied: Bool, imageName: String, onRemind: (() -> Void)?) {
|
|
||||||
deniedOverlay.configure(
|
|
||||||
title: "TA未开启设置",
|
|
||||||
message: "无法查看手机状态",
|
|
||||||
actionTitle: "提醒TA",
|
|
||||||
imageName: imageName
|
|
||||||
)
|
|
||||||
deniedOverlay.onAction = onRemind
|
|
||||||
deniedOverlay.setVisible(denied)
|
|
||||||
if denied { card.bringSubviewToFront(deniedOverlay) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSummary(_ text: String) {
|
|
||||||
summaryLab.text = text
|
|
||||||
}
|
|
||||||
|
|
||||||
func setBars(_ bars: [PhoneReportDetailViewModel.ChartBar]) {
|
|
||||||
chart.configure(
|
|
||||||
bars: bars,
|
|
||||||
barGradient: barGradient,
|
|
||||||
highlightGradient: highlightGradient,
|
|
||||||
tipColor: tipColor,
|
|
||||||
tipArrowImageName: tipArrowImageName,
|
|
||||||
suffix: valueSuffix
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class PhoneReportBarChartView: UIView {
|
|
||||||
private var bars: [PhoneReportDetailViewModel.ChartBar] = []
|
|
||||||
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 barFrames: [CGRect] = []
|
|
||||||
private var selectedIndex: Int?
|
|
||||||
private var barSpacing: CGFloat = 0
|
|
||||||
private var baselineY: CGFloat = 0
|
|
||||||
private let tipLab = UILabel()
|
|
||||||
private let tipArrowView = UIImageView()
|
|
||||||
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.textAlignment = .center
|
|
||||||
tipLab.cornerRadius = 6
|
|
||||||
tipLab.clipsToBounds = true
|
|
||||||
tipLab.isHidden = true
|
|
||||||
tipLab.isUserInteractionEnabled = false
|
|
||||||
addSubview(tipLab)
|
|
||||||
tipArrowView.contentMode = .scaleAspectFit
|
|
||||||
tipArrowView.isHidden = true
|
|
||||||
tipArrowView.isUserInteractionEnabled = false
|
|
||||||
addSubview(tipArrowView)
|
|
||||||
|
|
||||||
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],
|
|
||||||
barGradient: (UIColor, UIColor),
|
|
||||||
highlightGradient: (UIColor, UIColor),
|
|
||||||
tipColor: UIColor,
|
|
||||||
tipArrowImageName: String,
|
|
||||||
suffix: String
|
|
||||||
) {
|
|
||||||
let previous = self.bars
|
|
||||||
self.bars = bars
|
|
||||||
self.barGradient = barGradient
|
|
||||||
self.highlightGradient = highlightGradient
|
|
||||||
self.tipColor = tipColor
|
|
||||||
tipArrowView.image = UIImage(named: tipArrowImageName)
|
|
||||||
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
|
|
||||||
tipArrowView.isHidden = true
|
|
||||||
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 tipVerticalPadding: CGFloat = 8
|
|
||||||
let tipArrowHeight: CGFloat = 5
|
|
||||||
let tipArrowBarSpacing: CGFloat = 6
|
|
||||||
let tipHeight = ceil(tipLab.font.lineHeight) + tipVerticalPadding
|
|
||||||
let chartH = max(4, bounds.height - labelH - tipHeight - tipArrowHeight - tipArrowBarSpacing)
|
|
||||||
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 + 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
|
|
||||||
gradient.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
|
||||||
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 - 10, y: bounds.height - labelH, width: barW + 20, height: labelH)
|
|
||||||
insertSubview(lab, belowSubview: tipLab)
|
|
||||||
labelViews.append(lab)
|
|
||||||
|
|
||||||
if isSelected {
|
|
||||||
tipLab.text = tipText(for: bar)
|
|
||||||
tipLab.backgroundColor = tipColor
|
|
||||||
tipLab.sizeToFit()
|
|
||||||
let tipW = tipLab.bounds.width + 5 + 5
|
|
||||||
// 与柱子水平居中,允许超出 chart 左右边界
|
|
||||||
tipLab.frame = CGRect(
|
|
||||||
x: x + barW / 2 - tipW / 2,
|
|
||||||
y: max(0, y - tipHeight - tipArrowHeight - tipArrowBarSpacing),
|
|
||||||
width: tipW,
|
|
||||||
height: tipHeight
|
|
||||||
)
|
|
||||||
tipLab.isHidden = false
|
|
||||||
tipArrowView.frame = CGRect(
|
|
||||||
x: x + barW / 2 - 5,
|
|
||||||
y: tipLab.frame.maxY,
|
|
||||||
width: 10,
|
|
||||||
height: tipArrowHeight
|
|
||||||
)
|
|
||||||
tipArrowView.isHidden = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bringSubviewToFront(tipLab)
|
|
||||||
bringSubviewToFront(tipArrowView)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)小时\(mins)分" : "\(hours)小时"
|
|
||||||
}
|
|
||||||
return "\(mins)分钟"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class PhoneReportMemberTrailingFadeView: UIView {
|
|
||||||
override class var layerClass: AnyClass { CAGradientLayer.self }
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
|
||||||
super.init(frame: frame)
|
|
||||||
isUserInteractionEnabled = false
|
|
||||||
let gradientLayer = layer as! CAGradientLayer
|
|
||||||
gradientLayer.colors = [
|
|
||||||
UIColor.white.withAlphaComponent(0).cgColor,
|
|
||||||
UIColor.white.withAlphaComponent(0.88).cgColor,
|
|
||||||
UIColor.white.cgColor
|
|
||||||
]
|
|
||||||
gradientLayer.locations = [0, 0.7, 1]
|
|
||||||
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
|
|
||||||
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
|
|
||||||
gradientLayer.cornerRadius = 20
|
|
||||||
gradientLayer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,341 +0,0 @@
|
||||||
//
|
|
||||||
// PhoneReportDetailViewModel.swift
|
|
||||||
// QuickLocation
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import RxSwift
|
|
||||||
import RxCocoa
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
final class PhoneReportDetailViewModel {
|
|
||||||
|
|
||||||
struct ChartBar {
|
|
||||||
let label: String
|
|
||||||
let value: Int
|
|
||||||
let isToday: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StatusSnapshot {
|
|
||||||
let batteryText: String
|
|
||||||
let batteryPercent: Int?
|
|
||||||
let isCharging: Bool
|
|
||||||
let estimateText: String
|
|
||||||
let brightnessText: String
|
|
||||||
let volumeText: String
|
|
||||||
let wifiText: String
|
|
||||||
let deviceText: String
|
|
||||||
let storageText: String
|
|
||||||
let storageRatio: CGFloat
|
|
||||||
let brandIconURL: String
|
|
||||||
let screenTodayText: String
|
|
||||||
let appUsageTodayText: String
|
|
||||||
let screenBars: [ChartBar]
|
|
||||||
let appUsageBars: [ChartBar]
|
|
||||||
}
|
|
||||||
|
|
||||||
let members: [GroupMemberModel]
|
|
||||||
let selectedMemberId: BehaviorRelay<String>
|
|
||||||
let snapshot: BehaviorRelay<StatusSnapshot>
|
|
||||||
|
|
||||||
private let groupKey: String
|
|
||||||
private let disposeBag = DisposeBag()
|
|
||||||
private var statusObserveBag = DisposeBag()
|
|
||||||
private let placeholder = "**"
|
|
||||||
private var reportCache: [String: PhoneUsageReportModel] = [:]
|
|
||||||
private var requestIDs: [String: UUID] = [:]
|
|
||||||
|
|
||||||
init(members: [GroupMemberModel], selectedUserId: String, groupKey: String) {
|
|
||||||
self.members = members
|
|
||||||
self.groupKey = groupKey
|
|
||||||
self.selectedMemberId = BehaviorRelay(value: selectedUserId)
|
|
||||||
self.snapshot = BehaviorRelay(value: Self.emptySnapshot())
|
|
||||||
|
|
||||||
selectedMemberId
|
|
||||||
.asObservable()
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.subscribe(onNext: { [weak self] userId in
|
|
||||||
self?.refresh(userId: userId)
|
|
||||||
})
|
|
||||||
.disposed(by: disposeBag)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isCurrentUser(_ userId: String) -> Bool {
|
|
||||||
!userId.isEmpty && userId == AppContextManager.shared.userId
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refresh(userId: String) {
|
|
||||||
let isSelf = isCurrentUser(userId)
|
|
||||||
updateStatusObserving(isSelf: isSelf)
|
|
||||||
|
|
||||||
guard !userId.isEmpty else {
|
|
||||||
snapshot.accept(Self.emptySnapshot())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if groupKey.isEmpty {
|
|
||||||
snapshot.accept(isSelf ? makeLocalStatusSnapshot(report: nil) : Self.emptySnapshot())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let cacheKey = "\(groupKey)|\(userId)"
|
|
||||||
let cached = reportCache[cacheKey]
|
|
||||||
if isSelf {
|
|
||||||
snapshot.accept(makeLocalStatusSnapshot(report: cached))
|
|
||||||
} else if let cached {
|
|
||||||
snapshot.accept(makeRemoteSnapshot(report: cached))
|
|
||||||
} else {
|
|
||||||
snapshot.accept(Self.emptySnapshot())
|
|
||||||
}
|
|
||||||
|
|
||||||
guard requestIDs[cacheKey] == nil else { return }
|
|
||||||
let requestID = UUID()
|
|
||||||
requestIDs[cacheKey] = requestID
|
|
||||||
|
|
||||||
UserService.phoneUsageReport(userId: userId, groupKey: groupKey)
|
|
||||||
.subscribe(onNext: { [weak self] response in
|
|
||||||
guard let self, self.requestIDs[cacheKey] == requestID else { return }
|
|
||||||
self.requestIDs.removeValue(forKey: cacheKey)
|
|
||||||
guard response.code == "0", let report = response.model else {
|
|
||||||
guard self.selectedMemberId.value == userId, self.isCurrentUser(userId) else { return }
|
|
||||||
self.snapshot.accept(self.makeLocalStatusSnapshot(report: self.reportCache[cacheKey]))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.reportCache[cacheKey] = report
|
|
||||||
guard self.selectedMemberId.value == userId else { return }
|
|
||||||
self.snapshot.accept(self.makeSnapshot(report: report, userId: userId))
|
|
||||||
}, onError: { [weak self] _ in
|
|
||||||
guard let self, self.requestIDs[cacheKey] == requestID else { return }
|
|
||||||
self.requestIDs.removeValue(forKey: cacheKey)
|
|
||||||
guard self.selectedMemberId.value == userId, self.isCurrentUser(userId) else { return }
|
|
||||||
self.snapshot.accept(self.makeLocalStatusSnapshot(report: self.reportCache[cacheKey]))
|
|
||||||
})
|
|
||||||
.disposed(by: disposeBag)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateStatusObserving(isSelf: Bool) {
|
|
||||||
statusObserveBag = DisposeBag()
|
|
||||||
guard isSelf else { return }
|
|
||||||
PhoneStatusCollector.shared.start()
|
|
||||||
NotificationCenter.default.rx.notification(.phoneStatusDidChange)
|
|
||||||
.observe(on: MainScheduler.instance)
|
|
||||||
.subscribe(onNext: { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
let userId = self.selectedMemberId.value
|
|
||||||
guard self.isCurrentUser(userId) else { return }
|
|
||||||
let cacheKey = "\(self.groupKey)|\(userId)"
|
|
||||||
self.snapshot.accept(self.makeLocalStatusSnapshot(report: self.reportCache[cacheKey]))
|
|
||||||
})
|
|
||||||
.disposed(by: statusObserveBag)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeSnapshot(report: PhoneUsageReportModel, userId: String) -> StatusSnapshot {
|
|
||||||
if isCurrentUser(userId) {
|
|
||||||
return makeLocalStatusSnapshot(report: report)
|
|
||||||
}
|
|
||||||
return makeRemoteSnapshot(report: report)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeLocalStatusSnapshot(report: PhoneUsageReportModel?) -> StatusSnapshot {
|
|
||||||
let local = PhoneStatusCollector.shared.snapshot()
|
|
||||||
let charts = chartFields(from: report)
|
|
||||||
return StatusSnapshot(
|
|
||||||
batteryText: local.batteryPercent == nil ? placeholder : local.batteryText,
|
|
||||||
batteryPercent: local.batteryPercent,
|
|
||||||
isCharging: local.isCharging,
|
|
||||||
estimateText: Self.estimatedUsageText(
|
|
||||||
batteryPercent: local.batteryPercent,
|
|
||||||
placeholder: placeholder
|
|
||||||
),
|
|
||||||
brightnessText: local.brightnessText,
|
|
||||||
volumeText: local.volumeText,
|
|
||||||
wifiText: local.network,
|
|
||||||
deviceText: local.model,
|
|
||||||
storageText: local.memoryUsed == nil ? placeholder : local.storageText,
|
|
||||||
storageRatio: local.storageRatio,
|
|
||||||
brandIconURL: Self.validHTTPURLString(report?.brandIcon ?? "") ?? "",
|
|
||||||
screenTodayText: charts.screenTodayText,
|
|
||||||
appUsageTodayText: charts.appUsageTodayText,
|
|
||||||
screenBars: charts.screenBars,
|
|
||||||
appUsageBars: charts.appUsageBars
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeRemoteSnapshot(report: PhoneUsageReportModel) -> StatusSnapshot {
|
|
||||||
let info = report.phoneInfo
|
|
||||||
let charts = chartFields(from: report)
|
|
||||||
let batteryPercent = Self.percent(from: info?.battery)
|
|
||||||
return StatusSnapshot(
|
|
||||||
batteryText: Self.displayText(info?.battery, placeholder: placeholder),
|
|
||||||
batteryPercent: batteryPercent,
|
|
||||||
isCharging: false,
|
|
||||||
estimateText: Self.estimatedUsageText(
|
|
||||||
batteryPercent: batteryPercent,
|
|
||||||
placeholder: placeholder
|
|
||||||
),
|
|
||||||
brightnessText: Self.displayText(info?.brightness, placeholder: placeholder),
|
|
||||||
volumeText: Self.displayText(info?.volume, placeholder: placeholder),
|
|
||||||
wifiText: Self.displayText(info?.network, placeholder: placeholder),
|
|
||||||
deviceText: Self.displayText(info?.model, placeholder: placeholder),
|
|
||||||
storageText: Self.storageText(used: info?.memoryUsed, total: info?.memoryTotal, placeholder: placeholder),
|
|
||||||
storageRatio: Self.storageRatio(used: info?.memoryUsed, total: info?.memoryTotal),
|
|
||||||
brandIconURL: Self.validHTTPURLString(report.brandIcon) ?? "",
|
|
||||||
screenTodayText: charts.screenTodayText,
|
|
||||||
appUsageTodayText: charts.appUsageTodayText,
|
|
||||||
screenBars: charts.screenBars,
|
|
||||||
appUsageBars: charts.appUsageBars
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func chartFields(from report: PhoneUsageReportModel?) -> (
|
|
||||||
screenTodayText: String,
|
|
||||||
appUsageTodayText: String,
|
|
||||||
screenBars: [ChartBar],
|
|
||||||
appUsageBars: [ChartBar]
|
|
||||||
) {
|
|
||||||
guard let report else {
|
|
||||||
return (placeholder, placeholder, [], [])
|
|
||||||
}
|
|
||||||
let info = report.phoneInfo
|
|
||||||
let screenBars = Self.makeBars(from: report.screenUseTimes, value: { $0.useTime })
|
|
||||||
let appUsageBars = Self.makeBars(from: report.appUseCounts, value: { $0.count })
|
|
||||||
let todayScreenSeconds = Self.todayValue(in: report.screenUseTimes, value: { $0.useTime })
|
|
||||||
?? Self.nonnegativeInt(from: info?.useTime)
|
|
||||||
let todayAppUsageCount = Self.todayValue(in: report.appUseCounts, value: { $0.count })
|
|
||||||
let screenTodayText = todayScreenSeconds.map { Self.formatDuration($0) } ?? placeholder
|
|
||||||
let appUsageTodayText = todayAppUsageCount.map { "\($0)次" } ?? placeholder
|
|
||||||
return (screenTodayText, appUsageTodayText, screenBars, appUsageBars)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func emptySnapshot() -> StatusSnapshot {
|
|
||||||
StatusSnapshot(
|
|
||||||
batteryText: "**",
|
|
||||||
batteryPercent: nil,
|
|
||||||
isCharging: false,
|
|
||||||
estimateText: "**",
|
|
||||||
brightnessText: "**",
|
|
||||||
volumeText: "**",
|
|
||||||
wifiText: "**",
|
|
||||||
deviceText: "**",
|
|
||||||
storageText: "**",
|
|
||||||
storageRatio: 0,
|
|
||||||
brandIconURL: "",
|
|
||||||
screenTodayText: "**",
|
|
||||||
appUsageTodayText: "**",
|
|
||||||
screenBars: [],
|
|
||||||
appUsageBars: []
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func makeBars(
|
|
||||||
from entries: [PhoneUsageReportDayModel],
|
|
||||||
value: (PhoneUsageReportDayModel) -> Int?
|
|
||||||
) -> [ChartBar] {
|
|
||||||
entries.compactMap { entry -> (Date, Int)? in
|
|
||||||
guard let date = reportDateFormatter.date(from: entry.day),
|
|
||||||
let rawValue = value(entry) else { return nil }
|
|
||||||
return (date, max(0, rawValue))
|
|
||||||
}
|
|
||||||
.sorted { $0.0 < $1.0 }
|
|
||||||
.map { date, value in
|
|
||||||
ChartBar(
|
|
||||||
label: chartDateFormatter.string(from: date),
|
|
||||||
value: value,
|
|
||||||
isToday: Calendar.current.isDateInToday(date)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func todayValue(
|
|
||||||
in entries: [PhoneUsageReportDayModel],
|
|
||||||
value: (PhoneUsageReportDayModel) -> Int?
|
|
||||||
) -> Int? {
|
|
||||||
for entry in entries {
|
|
||||||
guard let date = reportDateFormatter.date(from: entry.day),
|
|
||||||
Calendar.current.isDateInToday(date),
|
|
||||||
let rawValue = value(entry) else { continue }
|
|
||||||
return max(0, rawValue)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let reportDateFormatter: DateFormatter = {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
formatter.calendar = Calendar(identifier: .gregorian)
|
|
||||||
formatter.timeZone = TimeZone.current
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let chartDateFormatter: DateFormatter = {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
formatter.calendar = Calendar(identifier: .gregorian)
|
|
||||||
formatter.timeZone = TimeZone.current
|
|
||||||
formatter.dateFormat = "M/d"
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static func displayText(_ value: String?, placeholder: String) -> String {
|
|
||||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
return text.isEmpty ? placeholder : text
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func nonnegativeInt(from value: String?) -> Int? {
|
|
||||||
guard let value,
|
|
||||||
let number = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) else { return nil }
|
|
||||||
return max(0, number)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func percent(from value: String?) -> Int? {
|
|
||||||
guard let number = firstNumber(in: value) else { return nil }
|
|
||||||
return max(0, min(100, Int(number.rounded())))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func estimatedUsageText(batteryPercent: Int?, placeholder: String) -> String {
|
|
||||||
guard let batteryPercent else { return placeholder }
|
|
||||||
let estimatedMinutes = batteryPercent * 9
|
|
||||||
let hours = max(1, Int((Double(estimatedMinutes) / 60).rounded()))
|
|
||||||
return "\(hours)小时"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func storageRatio(used: String?, total: String?) -> CGFloat {
|
|
||||||
guard let usedValue = firstNumber(in: used),
|
|
||||||
let totalValue = firstNumber(in: total),
|
|
||||||
totalValue > 0 else { return 0 }
|
|
||||||
return CGFloat(max(0, min(1, usedValue / totalValue)))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func storageText(used: String?, total: String?, placeholder: String) -> String {
|
|
||||||
let usedText = used?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
let totalText = total?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
guard !usedText.isEmpty, !totalText.isEmpty else { return placeholder }
|
|
||||||
return "\(usedText)/\(totalText)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func firstNumber(in value: String?) -> Double? {
|
|
||||||
guard let value,
|
|
||||||
let range = value.range(of: #"\d+(?:\.\d+)?"#, options: .regularExpression) else { return nil }
|
|
||||||
return Double(value[range])
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func validHTTPURLString(_ value: String) -> String? {
|
|
||||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard let components = URLComponents(string: text),
|
|
||||||
let scheme = components.scheme?.lowercased(),
|
|
||||||
scheme == "http" || scheme == "https",
|
|
||||||
components.host != nil else { return nil }
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatDuration(_ seconds: Int) -> String {
|
|
||||||
let hours = seconds / 3600
|
|
||||||
let minutes = (seconds % 3600) / 60
|
|
||||||
if hours > 0 {
|
|
||||||
return minutes > 0 ? "\(hours)小时\(minutes)分" : "\(hours)小时"
|
|
||||||
}
|
|
||||||
return "\(minutes)分钟"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
//
|
||||||
|
// LaunchPrivacyConsentView.swift
|
||||||
|
// QuickLocation
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class LaunchPrivacyConsentView: UIView, UITextViewDelegate {
|
||||||
|
|
||||||
|
var onAgree: (() -> Void)?
|
||||||
|
var onDisagree: (() -> Void)?
|
||||||
|
var onOpenURL: ((String) -> Void)?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
setupUI()
|
||||||
|
configureBody()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func show(in host: UIView) {
|
||||||
|
frame = host.bounds
|
||||||
|
autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
host.addSubview(self)
|
||||||
|
layoutIfNeeded()
|
||||||
|
dimView.alpha = 0
|
||||||
|
card.transform = CGAffineTransform(translationX: 0, y: bounds.height * 0.4)
|
||||||
|
UIView.animate(withDuration: 0.32, delay: 0, options: [.curveEaseOut]) {
|
||||||
|
self.dimView.alpha = 1
|
||||||
|
self.card.transform = .identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
addSubview(dimView)
|
||||||
|
addSubview(card)
|
||||||
|
card.addSubview(gradientView)
|
||||||
|
card.addSubview(titleLab)
|
||||||
|
card.addSubview(bodyTextView)
|
||||||
|
card.addSubview(agreeBtn)
|
||||||
|
card.addSubview(disagreeBtn)
|
||||||
|
|
||||||
|
dimView.layoutChain.edges()
|
||||||
|
|
||||||
|
card.layoutChain
|
||||||
|
.edgesHorzontal()
|
||||||
|
.bottom()
|
||||||
|
|
||||||
|
gradientView.layoutChain
|
||||||
|
.top()
|
||||||
|
.edgesHorzontal()
|
||||||
|
.height(120)
|
||||||
|
|
||||||
|
titleLab.layoutChain
|
||||||
|
.top(28)
|
||||||
|
.edgesHorzontal(24)
|
||||||
|
.height(28)
|
||||||
|
|
||||||
|
bodyTextView.layoutChain
|
||||||
|
.topToBottomOfView(titleLab, offset: 16)
|
||||||
|
.edgesHorzontal(24)
|
||||||
|
|
||||||
|
agreeBtn.layoutChain
|
||||||
|
.topToBottomOfView(bodyTextView, offset: 22)
|
||||||
|
.edgesHorzontal(30)
|
||||||
|
.height(56)
|
||||||
|
|
||||||
|
disagreeBtn.layoutChain
|
||||||
|
.topToBottomOfView(agreeBtn, offset: 12)
|
||||||
|
.centerX()
|
||||||
|
.bottom(kSafeBottomMargin + 8)
|
||||||
|
.height(24)
|
||||||
|
|
||||||
|
agreeBtn.addTarget(self, action: #selector(handleAgree), for: .touchUpInside)
|
||||||
|
disagreeBtn.addTarget(self, action: #selector(handleDisagree), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureBody() {
|
||||||
|
let full = """
|
||||||
|
请你知悉,为保障为您正常提供相应服务,当您在未登录/未注册状态下进行浏览和搜索时,需要开启以下权限:
|
||||||
|
1.个人信息类型:设备信息(包括设备型号、设备标识符)。
|
||||||
|
2.位置权限(始终允许),您同意开启设备定位服务后,我们会通过GPS或WiFi等方式持续收集您的地理位置信息,将用于为您记录位置信息和为您的好友发送位置信息。您可以通过关闭手机定位服务,停止我们对您的地理位置信息进行收集,同时我们将对您的好友停止更新您的位置。
|
||||||
|
想了解更多服务协议、隐私内容和使用第三方SDK详情,请阅读我们的《用户服务协议》和《隐私政策》。
|
||||||
|
"""
|
||||||
|
let paragraph = NSMutableParagraphStyle()
|
||||||
|
paragraph.lineSpacing = 5
|
||||||
|
paragraph.paragraphSpacing = 8
|
||||||
|
|
||||||
|
let attributed = NSMutableAttributedString(
|
||||||
|
string: full,
|
||||||
|
attributes: [
|
||||||
|
.font: UIFont.systemFont(ofSize: 14, weight: .regular),
|
||||||
|
.foregroundColor: UIColor(hexStr: "#333333"),
|
||||||
|
.paragraphStyle: paragraph
|
||||||
|
]
|
||||||
|
)
|
||||||
|
let agreementRange = (full as NSString).range(of: "《用户服务协议》")
|
||||||
|
let privacyRange = (full as NSString).range(of: "《隐私政策》")
|
||||||
|
if agreementRange.location != NSNotFound {
|
||||||
|
attributed.addAttributes([
|
||||||
|
.foregroundColor: UIColor(hexStr: "#16B3FF"),
|
||||||
|
.link: "UserAgreement"
|
||||||
|
], range: agreementRange)
|
||||||
|
}
|
||||||
|
if privacyRange.location != NSNotFound {
|
||||||
|
attributed.addAttributes([
|
||||||
|
.foregroundColor: UIColor(hexStr: "#16B3FF"),
|
||||||
|
.link: "PrivacyPolicy"
|
||||||
|
], range: privacyRange)
|
||||||
|
}
|
||||||
|
bodyTextView.attributedText = attributed
|
||||||
|
bodyTextView.linkTextAttributes = [
|
||||||
|
.foregroundColor: UIColor(hexStr: "#16B3FF"),
|
||||||
|
.underlineStyle: NSUnderlineStyle.single.rawValue
|
||||||
|
]
|
||||||
|
bodyTextView.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func textView(
|
||||||
|
_ textView: UITextView,
|
||||||
|
shouldInteractWith URL: URL,
|
||||||
|
in characterRange: NSRange,
|
||||||
|
interaction: UITextItemInteraction
|
||||||
|
) -> Bool {
|
||||||
|
switch URL.absoluteString {
|
||||||
|
case "UserAgreement":
|
||||||
|
onOpenURL?(URLManager.shared.userAgreementUrl)
|
||||||
|
case "PrivacyPolicy":
|
||||||
|
onOpenURL?(URLManager.shared.privacyPolicyUrl)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { false }
|
||||||
|
|
||||||
|
@objc private func handleAgree() {
|
||||||
|
onAgree?()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleDisagree() {
|
||||||
|
onDisagree?()
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var dimView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = UIColor.black.withAlphaComponent(0.45)
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var card: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .white
|
||||||
|
view.layer.cornerRadius = 28
|
||||||
|
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
|
view.clipsToBounds = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var gradientView: LaunchPrivacyGradientView = {
|
||||||
|
let view = LaunchPrivacyGradientView()
|
||||||
|
view.isUserInteractionEnabled = false
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var titleLab: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "服务协议及隐私政策"
|
||||||
|
label.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||||
|
label.textColor = UIColor(hexStr: "#1A1A1A")
|
||||||
|
label.textAlignment = .center
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var bodyTextView: UITextView = {
|
||||||
|
let textView = UITextView()
|
||||||
|
textView.backgroundColor = .clear
|
||||||
|
textView.isEditable = false
|
||||||
|
textView.isScrollEnabled = false
|
||||||
|
textView.isSelectable = true
|
||||||
|
textView.textContainerInset = .zero
|
||||||
|
textView.textContainer.lineFragmentPadding = 0
|
||||||
|
return textView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var agreeBtn: UIButton = {
|
||||||
|
let btn = UIButton(type: .custom)
|
||||||
|
btn.cornerRadius = 20
|
||||||
|
btn.setTitle("同意并进入", for: .normal)
|
||||||
|
btn.setTitleColor(.white, for: .normal)
|
||||||
|
btn.titleLabel?.font = FontManager.boboBold(18)
|
||||||
|
btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal)
|
||||||
|
return btn
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var disagreeBtn: UIButton = {
|
||||||
|
let btn = UIButton(type: .system)
|
||||||
|
btn.setTitle("不同意", for: .normal)
|
||||||
|
btn.setTitleColor(UIColor(hexStr: "#16B3FF"), for: .normal)
|
||||||
|
btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .regular)
|
||||||
|
return btn
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class LaunchPrivacyGradientView: UIView {
|
||||||
|
override class var layerClass: AnyClass { CAGradientLayer.self }
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
guard let layer = layer as? CAGradientLayer else { return }
|
||||||
|
layer.colors = [
|
||||||
|
UIColor(hexStr: "#D9F8F0").cgColor,
|
||||||
|
UIColor.white.cgColor
|
||||||
|
]
|
||||||
|
layer.startPoint = CGPoint(x: 0.5, y: 0)
|
||||||
|
layer.endPoint = CGPoint(x: 0.5, y: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,16 +13,63 @@ import RxCocoa
|
||||||
class LaunchViewController: BaseViewController {
|
class LaunchViewController: BaseViewController {
|
||||||
|
|
||||||
private let showVersion = 1
|
private let showVersion = 1
|
||||||
|
private var privacyConsentView: LaunchPrivacyConsentView?
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
// Do any additional setup after loading the view.
|
|
||||||
view.backgroundColor = .white
|
view.backgroundColor = .white
|
||||||
setupLayout()
|
setupLayout()
|
||||||
|
if Defaults[\.didAgreeLaunchPrivacy] {
|
||||||
|
proceedAfterPrivacyAgreed()
|
||||||
|
} else {
|
||||||
|
showPrivacyConsentIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func proceedAfterPrivacyAgreed() {
|
||||||
|
AppDelegate.shared.setupPrivacyGrantedSDKs()
|
||||||
getUserConfig()
|
getUserConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showPrivacyConsentIfNeeded() {
|
||||||
|
let consent = LaunchPrivacyConsentView()
|
||||||
|
privacyConsentView = consent
|
||||||
|
consent.onAgree = { [weak self] in
|
||||||
|
Defaults[\.didAgreeLaunchPrivacy] = true
|
||||||
|
self?.privacyConsentView?.removeFromSuperview()
|
||||||
|
self?.privacyConsentView = nil
|
||||||
|
self?.proceedAfterPrivacyAgreed()
|
||||||
|
}
|
||||||
|
consent.onDisagree = { [weak self] in
|
||||||
|
self?.handlePrivacyDisagree()
|
||||||
|
}
|
||||||
|
consent.onOpenURL = { [weak self] url in
|
||||||
|
self?.openPrivacyWeb(url)
|
||||||
|
}
|
||||||
|
consent.show(in: view)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePrivacyDisagree() {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "需同意协议后才能使用",
|
||||||
|
message: "若不同意服务协议及隐私政策,将无法进入极速定位。",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "退出应用", style: .destructive) { _ in
|
||||||
|
exit(0)
|
||||||
|
})
|
||||||
|
alert.addAction(UIAlertAction(title: "返回", style: .cancel))
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openPrivacyWeb(_ url: String) {
|
||||||
|
let vc = WebViewController(url: url, isShare: false, fullscreen: false, style: "default")
|
||||||
|
let nav = UINavigationController(rootViewController: vc)
|
||||||
|
nav.modalPresentationStyle = .fullScreen
|
||||||
|
present(nav, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取用户配置
|
/// 获取用户配置
|
||||||
func getUserConfig() {
|
func getUserConfig() {
|
||||||
SystemService.userConfig().subscribe(onNext: { response in
|
SystemService.userConfig().subscribe(onNext: { response in
|
||||||
|
|
@ -45,52 +92,11 @@ class LaunchViewController: BaseViewController {
|
||||||
DLAlert.show(title: error.localizedDescription,
|
DLAlert.show(title: error.localizedDescription,
|
||||||
defaultTitle: "重试") { [weak self] in
|
defaultTitle: "重试") { [weak self] in
|
||||||
guard let this = self else { return }
|
guard let this = self else { return }
|
||||||
// #if DEBUG || AdHoc
|
|
||||||
// this.showServerSwitchAlert()
|
|
||||||
// #else
|
|
||||||
this.getUserConfig()
|
this.getUserConfig()
|
||||||
// #endif
|
|
||||||
}
|
}
|
||||||
}).disposed(by: disposeBag)
|
}).disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showServerSwitchAlert() {
|
|
||||||
let current = URLManager.shared.apiServerURL
|
|
||||||
let alert = UIAlertController(
|
|
||||||
title: "切换接口",
|
|
||||||
message: "当前:\(current)",
|
|
||||||
preferredStyle: .alert
|
|
||||||
)
|
|
||||||
alert.addTextField { field in
|
|
||||||
field.placeholder = "自定义接口地址"
|
|
||||||
field.text = current
|
|
||||||
field.keyboardType = .URL
|
|
||||||
field.autocorrectionType = .no
|
|
||||||
field.autocapitalizationType = .none
|
|
||||||
}
|
|
||||||
for preset in URLManager.presetServers {
|
|
||||||
alert.addAction(UIAlertAction(title: preset.title, style: .default) { [weak self] _ in
|
|
||||||
self?.applyServer(url: preset.url, env: preset.env)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
|
|
||||||
let typed = alert.textFields?.first?.text ?? ""
|
|
||||||
self?.applyServer(url: typed, env: nil)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyServer(url: String, env: Int?) {
|
|
||||||
let normalized = URLManager.normalizedServerURL(url)
|
|
||||||
guard !normalized.isEmpty else {
|
|
||||||
DLToast.show(text: "请输入接口地址")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
URLManager.shared.switchServer(url: normalized, env: env)
|
|
||||||
AppDelegate.shared.showMainViewController()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func continueAfterConfig(model: UserConfigModel) {
|
private func continueAfterConfig(model: UserConfigModel) {
|
||||||
AppDelegate.shared.showMainViewController()
|
AppDelegate.shared.showMainViewController()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -457,7 +457,7 @@ class LoginView: UIView {
|
||||||
let btn = UIButton(type: .custom)
|
let btn = UIButton(type: .custom)
|
||||||
btn.setImage(UIImage(named: "Login/checkbox"), for: .normal)
|
btn.setImage(UIImage(named: "Login/checkbox"), for: .normal)
|
||||||
btn.setImage(UIImage(named: "Login/selected"), for: .selected)
|
btn.setImage(UIImage(named: "Login/selected"), for: .selected)
|
||||||
btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 150, bottom: 150, right: 50)
|
btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 150, bottom: 50, right: 50)
|
||||||
return btn
|
return btn
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,11 +123,19 @@ final class LoginViewModel: BaseViewModel {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
func performWechatLogin() {
|
func performWechatLogin() {
|
||||||
|
guard WXApi.isWXAppInstalled() else {
|
||||||
|
DLToast.showError(text: "请先安装微信")
|
||||||
|
return
|
||||||
|
}
|
||||||
NotificationCenter.default.post(name: .pausePopupQueue, object: nil)
|
NotificationCenter.default.post(name: .pausePopupQueue, object: nil)
|
||||||
let req = SendAuthReq()
|
let req = SendAuthReq()
|
||||||
req.scope = "snsapi_userinfo"
|
req.scope = "snsapi_userinfo"
|
||||||
req.state = "login"
|
req.state = "login"
|
||||||
WXApi.send(req)
|
WXApi.send(req) { success in
|
||||||
|
if !success {
|
||||||
|
DLToast.showError(text: "无法打开微信")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func performAppleLogin() {
|
func performAppleLogin() {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ import RxCocoa
|
||||||
final class AboutVC: BaseViewController {
|
final class AboutVC: BaseViewController {
|
||||||
|
|
||||||
private var rootView: AboutView!
|
private var rootView: AboutView!
|
||||||
private var logoTapCount = 0
|
|
||||||
private var logoTapResetWork: DispatchWorkItem?
|
|
||||||
|
|
||||||
override func loadView() {
|
override func loadView() {
|
||||||
rootView = AboutView(frame: UIScreen.main.bounds)
|
rootView = AboutView(frame: UIScreen.main.bounds)
|
||||||
|
|
@ -28,9 +26,6 @@ final class AboutVC: BaseViewController {
|
||||||
self?.navigationController?.popViewController(animated: true)
|
self?.navigationController?.popViewController(animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
let logoTap = UITapGestureRecognizer(target: self, action: #selector(handleLogoTap))
|
|
||||||
rootView.logoImage.addGestureRecognizer(logoTap)
|
|
||||||
|
|
||||||
rootView.feedbackBtn.rx.controlEvent(.touchUpInside)
|
rootView.feedbackBtn.rx.controlEvent(.touchUpInside)
|
||||||
.subscribe(onNext: { _ in
|
.subscribe(onNext: { _ in
|
||||||
AppRouter.push(Route.feedback)
|
AppRouter.push(Route.feedback)
|
||||||
|
|
@ -62,60 +57,6 @@ final class AboutVC: BaseViewController {
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleLogoTap() {
|
|
||||||
#if DEBUG || AdHoc
|
|
||||||
logoTapResetWork?.cancel()
|
|
||||||
logoTapCount += 1
|
|
||||||
if logoTapCount >= 5 {
|
|
||||||
logoTapCount = 0
|
|
||||||
showServerSwitchAlert()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let work = DispatchWorkItem { [weak self] in
|
|
||||||
self?.logoTapCount = 0
|
|
||||||
}
|
|
||||||
logoTapResetWork = work
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: work)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showServerSwitchAlert() {
|
|
||||||
let current = URLManager.shared.apiServerURL
|
|
||||||
let alert = UIAlertController(
|
|
||||||
title: "切换接口",
|
|
||||||
message: "当前:\(current)",
|
|
||||||
preferredStyle: .alert
|
|
||||||
)
|
|
||||||
alert.addTextField { field in
|
|
||||||
field.placeholder = "自定义接口地址"
|
|
||||||
field.text = current
|
|
||||||
field.keyboardType = .URL
|
|
||||||
field.autocorrectionType = .no
|
|
||||||
field.autocapitalizationType = .none
|
|
||||||
}
|
|
||||||
for preset in URLManager.presetServers {
|
|
||||||
alert.addAction(UIAlertAction(title: preset.title, style: .default) { [weak self] _ in
|
|
||||||
self?.applyServer(url: preset.url, env: preset.env)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
|
|
||||||
let typed = alert.textFields?.first?.text ?? ""
|
|
||||||
self?.applyServer(url: typed, env: nil)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyServer(url: String, env: Int?) {
|
|
||||||
let normalized = URLManager.normalizedServerURL(url)
|
|
||||||
guard !normalized.isEmpty else {
|
|
||||||
DLToast.show(text: "请输入接口地址")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
URLManager.shared.switchServer(url: normalized, env: env)
|
|
||||||
AppDelegate.shared.showMainViewController()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func openAppStoreReview() {
|
private static func openAppStoreReview() {
|
||||||
// Bundle 未配置 appId 时给提示;有则跳转写评价页
|
// Bundle 未配置 appId 时给提示;有则跳转写评价页
|
||||||
let appId = AppSettings.shared.appId
|
let appId = AppSettings.shared.appId
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,11 @@ import RxSwift
|
||||||
import Kingfisher
|
import Kingfisher
|
||||||
|
|
||||||
enum PrivacySettingItem: CaseIterable {
|
enum PrivacySettingItem: CaseIterable {
|
||||||
case phoneReport
|
|
||||||
case groupLock
|
case groupLock
|
||||||
case pigeonMessage
|
case pigeonMessage
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .phoneReport:
|
|
||||||
return "手机报告"
|
|
||||||
case .groupLock:
|
case .groupLock:
|
||||||
return "一键锁机"
|
return "一键锁机"
|
||||||
case .pigeonMessage:
|
case .pigeonMessage:
|
||||||
|
|
@ -25,8 +22,6 @@ enum PrivacySettingItem: CaseIterable {
|
||||||
|
|
||||||
var imageName: String {
|
var imageName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .phoneReport:
|
|
||||||
return "PrivacySettings/phone_report"
|
|
||||||
case .groupLock:
|
case .groupLock:
|
||||||
return "PrivacySettings/group_lock"
|
return "PrivacySettings/group_lock"
|
||||||
case .pigeonMessage:
|
case .pigeonMessage:
|
||||||
|
|
@ -36,8 +31,6 @@ enum PrivacySettingItem: CaseIterable {
|
||||||
|
|
||||||
var apiKey: String {
|
var apiKey: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .phoneReport:
|
|
||||||
return "phone_report"
|
|
||||||
case .groupLock:
|
case .groupLock:
|
||||||
return "app_lock"
|
return "app_lock"
|
||||||
case .pigeonMessage:
|
case .pigeonMessage:
|
||||||
|
|
@ -208,8 +201,6 @@ final class PrivacySettingsViewController: BaseViewController {
|
||||||
let previous = groups[index].privilege
|
let previous = groups[index].privilege
|
||||||
var updated = previous
|
var updated = previous
|
||||||
switch item {
|
switch item {
|
||||||
case .phoneReport:
|
|
||||||
updated.phone_report = isOn
|
|
||||||
case .groupLock:
|
case .groupLock:
|
||||||
updated.app_lock = isOn
|
updated.app_lock = isOn
|
||||||
case .pigeonMessage:
|
case .pigeonMessage:
|
||||||
|
|
@ -266,7 +257,6 @@ final class PrivacySettingsView: UIView {
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let contentView = UIView()
|
private let contentView = UIView()
|
||||||
private let groupSelectorView = PrivacyGroupSelectorView()
|
private let groupSelectorView = PrivacyGroupSelectorView()
|
||||||
private let phoneReportRow = PrivacySettingRow(item: .phoneReport)
|
|
||||||
private let groupLockRow = PrivacySettingRow(item: .groupLock)
|
private let groupLockRow = PrivacySettingRow(item: .groupLock)
|
||||||
private let pigeonMessageRow = PrivacySettingRow(item: .pigeonMessage)
|
private let pigeonMessageRow = PrivacySettingRow(item: .pigeonMessage)
|
||||||
private let footerLabel = UILabel()
|
private let footerLabel = UILabel()
|
||||||
|
|
@ -286,13 +276,12 @@ final class PrivacySettingsView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
func apply(_ privilege: GroupPrivilege) {
|
func apply(_ privilege: GroupPrivilege) {
|
||||||
phoneReportRow.setOn(privilege.phone_report)
|
|
||||||
groupLockRow.setOn(privilege.app_lock)
|
groupLockRow.setOn(privilege.app_lock)
|
||||||
pigeonMessageRow.setOn(privilege.pigeon_mail)
|
pigeonMessageRow.setOn(privilege.pigeon_mail)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSettingsEnabled(_ enabled: Bool) {
|
func setSettingsEnabled(_ enabled: Bool) {
|
||||||
[phoneReportRow, groupLockRow, pigeonMessageRow].forEach {
|
[groupLockRow, pigeonMessageRow].forEach {
|
||||||
$0.setSwitchEnabled(enabled)
|
$0.setSwitchEnabled(enabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -318,10 +307,6 @@ final class PrivacySettingsView: UIView {
|
||||||
|
|
||||||
contentView.addSubview(groupSelectorView)
|
contentView.addSubview(groupSelectorView)
|
||||||
|
|
||||||
let shareHeader = makeSectionHeader(title: "圈子中可以共享的数据")
|
|
||||||
contentView.addSubview(shareHeader)
|
|
||||||
contentView.addSubview(phoneReportRow)
|
|
||||||
|
|
||||||
let ownerHeader = makeSectionHeader(title: "圈主可以对你使用的功能")
|
let ownerHeader = makeSectionHeader(title: "圈主可以对你使用的功能")
|
||||||
contentView.addSubview(ownerHeader)
|
contentView.addSubview(ownerHeader)
|
||||||
contentView.addSubview(groupLockRow)
|
contentView.addSubview(groupLockRow)
|
||||||
|
|
@ -357,20 +342,8 @@ final class PrivacySettingsView: UIView {
|
||||||
.right(15)
|
.right(15)
|
||||||
.height(100)
|
.height(100)
|
||||||
|
|
||||||
shareHeader.layoutChain
|
|
||||||
.topToBottomOfView(groupSelectorView, offset: 20)
|
|
||||||
.left(17)
|
|
||||||
.right(15)
|
|
||||||
.height(24)
|
|
||||||
|
|
||||||
phoneReportRow.layoutChain
|
|
||||||
.topToBottomOfView(shareHeader, offset: 12)
|
|
||||||
.left(15)
|
|
||||||
.right(15)
|
|
||||||
.height(64)
|
|
||||||
|
|
||||||
ownerHeader.layoutChain
|
ownerHeader.layoutChain
|
||||||
.topToBottomOfView(phoneReportRow, offset: 18)
|
.topToBottomOfView(groupSelectorView, offset: 20)
|
||||||
.left(17)
|
.left(17)
|
||||||
.right(15)
|
.right(15)
|
||||||
.height(24)
|
.height(24)
|
||||||
|
|
@ -391,9 +364,6 @@ final class PrivacySettingsView: UIView {
|
||||||
groupSelectorView.onSelectionChanged = { [weak self] in
|
groupSelectorView.onSelectionChanged = { [weak self] in
|
||||||
self?.onGroupChanged?()
|
self?.onGroupChanged?()
|
||||||
}
|
}
|
||||||
phoneReportRow.onToggle = { [weak self] isOn in
|
|
||||||
self?.onToggle?(.phoneReport, isOn)
|
|
||||||
}
|
|
||||||
groupLockRow.onToggle = { [weak self] isOn in
|
groupLockRow.onToggle = { [weak self] isOn in
|
||||||
self?.onToggle?(.groupLock, isOn)
|
self?.onToggle?(.groupLock, isOn)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,14 +62,6 @@ struct UserService {
|
||||||
.asObservable()
|
.asObservable()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 指定圈子成员的手机使用报告
|
|
||||||
static func phoneUsageReport(userId: String, groupKey: String) -> Observable<PhoneUsageReportResponse> {
|
|
||||||
let api = UserAPI.phoneUsageReport(userId: userId, groupKey: groupKey).multiTarget
|
|
||||||
return APIProvider.request(token: api, handle: false)
|
|
||||||
.map(PhoneUsageReportResponse.self)
|
|
||||||
.asObservable()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 指定成员某日历史行程
|
/// 指定成员某日历史行程
|
||||||
static func phoneUsageTrips(userId: String, date: Int64) -> Observable<PhoneUsageTripsResponse> {
|
static func phoneUsageTrips(userId: String, date: Int64) -> Observable<PhoneUsageTripsResponse> {
|
||||||
let api = UserAPI.phoneUsageTrips(userId: userId, date: date).multiTarget
|
let api = UserAPI.phoneUsageTrips(userId: userId, date: date).multiTarget
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue