parent
25aac678ac
commit
fb4a63d10b
|
|
@ -103,6 +103,7 @@
|
|||
305A76E22FCA8C7000227D26 /* GroupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76252FCA8C7000227D26 /* GroupViewModel.swift */; };
|
||||
305A76E52FCA8C7000227D26 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76292FCA8C7000227D26 /* HomeViewController.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 */; };
|
||||
305A76E82FCA8C7000227D26 /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A762E2FCA8C7000227D26 /* LoginViewController.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 */; };
|
||||
55B217B13022F0A000784711 /* GroupItineraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217B03022F0A000784710 /* GroupItineraryView.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 */; };
|
||||
55B217D13022F0A000784727 /* UnlockCountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D03022F0A000784726 /* UnlockCountManager.swift */; };
|
||||
55B217D23023010000784731 /* TodayTrackDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D13023010000784730 /* TodayTrackDetailVC.swift */; };
|
||||
55B217D43023010000784733 /* TodayTrackDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D33023010000784732 /* TodayTrackDetailView.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 */; };
|
||||
55B218A13024A00100784704 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784703 /* AboutView.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -724,15 +721,10 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -821,29 +813,21 @@
|
|||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
30CCDF8E2FE3E63B00F5214A /* sound */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = sound;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
30CCDF902FE3E63B00F5214A /* video */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = video;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
30CCE01E2FE3E64700F5214A /* lotties */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = lotties;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
30D87CEF2FDFF52100E958FD /* TTGTagCollectionView */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = TTGTagCollectionView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
|
|
@ -1193,7 +1177,6 @@
|
|||
305A75262FCA8C7000227D26 /* Money */,
|
||||
305A75282FCA8C7000227D26 /* Notification */,
|
||||
305A752A2FCA8C7000227D26 /* Theme */,
|
||||
55B217D23022F0A000784728 /* Unlock */,
|
||||
305A752C2FCA8C7000227D26 /* URL */,
|
||||
30D87CD52FDF9F1900E958FD /* MQTT */,
|
||||
2F518AB6587CC227CC515D8C /* AppRestrict */,
|
||||
|
|
@ -1297,10 +1280,8 @@
|
|||
55B250013050000100784901 /* LockedAppPopView.swift */,
|
||||
55B219B1302A000100784760 /* ReceiveMessagePopView.swift */,
|
||||
55B219B3302A000100784762 /* ReceiveMessageReportPopView.swift */,
|
||||
55B217C23022F0A000784722 /* MemberPhoneReportView.swift */,
|
||||
55B217C43022F0A000784724 /* MemberAppUsageView.swift */,
|
||||
55B217DD302301000078473C /* TodayTrackDetail */,
|
||||
55B217DE302301000078473D /* PhoneReportDetail */,
|
||||
55B240063040000100784906 /* AppUsageReport */,
|
||||
30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */,
|
||||
30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */,
|
||||
|
|
@ -1318,6 +1299,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
305A762B2FCA8C7000227D26 /* LaunchViewController.swift */,
|
||||
55B2A1003022F0A000784900 /* LaunchPrivacyConsentView.swift */,
|
||||
);
|
||||
path = Launch;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1967,14 +1949,6 @@
|
|||
path = Font;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
55B217D23022F0A000784728 /* Unlock */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
55B217D03022F0A000784726 /* UnlockCountManager.swift */,
|
||||
);
|
||||
path = Unlock;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
55B217DD302301000078473C /* TodayTrackDetail */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -1985,16 +1959,6 @@
|
|||
path = TodayTrackDetail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
55B217DE302301000078473D /* PhoneReportDetail */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
55B217D73023010000784736 /* PhoneReportDetailVC.swift */,
|
||||
55B217D93023010000784738 /* PhoneReportDetailView.swift */,
|
||||
55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */,
|
||||
);
|
||||
path = PhoneReportDetail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
55B218A13024A00100784710 /* About */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -2129,7 +2093,6 @@
|
|||
51E818049FA5A8557C434E37 /* ImageCropView.swift */,
|
||||
C39ADD47D3C81F01F6289AC4 /* ImageCropVC.swift */,
|
||||
);
|
||||
name = ImageCrop;
|
||||
path = ImageCrop;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
|
|
@ -2509,14 +2472,10 @@
|
|||
305A76C22FCA8C7000227D26 /* BaseViewModel.swift in Sources */,
|
||||
55B2179330218CE100784774 /* GroupMemberView2.swift in Sources */,
|
||||
55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */,
|
||||
55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */,
|
||||
55C1A0213034102000786021 /* PrivilegeDeniedOverlay.swift in Sources */,
|
||||
55B217D23023010000784731 /* TodayTrackDetailVC.swift in Sources */,
|
||||
55B217D43023010000784733 /* TodayTrackDetailView.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 */,
|
||||
55B240033040000100784903 /* AppUsageReportView.swift in Sources */,
|
||||
55B240053040000100784905 /* AppUsageReportViewModel.swift in Sources */,
|
||||
|
|
@ -2569,7 +2528,6 @@
|
|||
305A76D02FCA8C7000227D26 /* MoneyFormatter.swift in Sources */,
|
||||
305A76D12FCA8C7000227D26 /* TimeSpecificNotificationManager.swift in Sources */,
|
||||
305A76D22FCA8C7000227D26 /* ThemeManager.swift in Sources */,
|
||||
55B217D13022F0A000784727 /* UnlockCountManager.swift in Sources */,
|
||||
305A76D32FCA8C7000227D26 /* URLManager.swift in Sources */,
|
||||
305A76D42FCA8C7000227D26 /* GroupModel.swift in Sources */,
|
||||
55B230073032000100785008 /* PigeonModel.swift in Sources */,
|
||||
|
|
@ -2615,6 +2573,7 @@
|
|||
30BAB84F2FCD2FED00C33B5C /* InviteJoinVC.swift in Sources */,
|
||||
305A76E52FCA8C7000227D26 /* HomeViewController.swift in Sources */,
|
||||
305A76E62FCA8C7000227D26 /* LaunchViewController.swift in Sources */,
|
||||
55B2A1013022F0A000784901 /* LaunchPrivacyConsentView.swift in Sources */,
|
||||
3062E8B52FCE6BBA00CEF511 /* ScanVC.swift in Sources */,
|
||||
30A1C0A12FEB000100CA0001 /* CameraCaptureView.swift in Sources */,
|
||||
30A1C0A32FEB000100CA0001 /* CameraCaptureVC.swift in Sources */,
|
||||
|
|
@ -2794,9 +2753,12 @@
|
|||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
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;
|
||||
DEVELOPMENT_TEAM = LRDLWZ2Y83;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = DeviceActivityMonitorExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
|
|
@ -2808,6 +2770,8 @@
|
|||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca.DeviceActivityMonitor;
|
||||
PRODUCT_NAME = DeviceActivityMonitorExtension;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "jsdw DeviceActivityMonitor AppStore";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
|
|
@ -2852,11 +2816,9 @@
|
|||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = QuickLocation/QuickLocation.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = LRDLWZ2Y83;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = QuickLocation/Info.plist;
|
||||
|
|
@ -2883,7 +2845,6 @@
|
|||
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = jsdw_dev;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
|
|
@ -2907,9 +2868,9 @@
|
|||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = QuickLocation/QuickLocation.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)";
|
||||
|
|
@ -2938,7 +2899,7 @@
|
|||
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = jsdw_dev;
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = jsdw_AppStore;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
|
|
@ -3142,7 +3103,7 @@
|
|||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)";
|
||||
|
|
@ -3285,9 +3246,12 @@
|
|||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
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;
|
||||
DEVELOPMENT_TEAM = LRDLWZ2Y83;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = ShieldConfigurationExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
|
|
@ -3299,6 +3263,8 @@
|
|||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca.ShieldConfiguration;
|
||||
PRODUCT_NAME = ShieldConfigurationExtension;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "jsdw ShieldConfiguration AppStore";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
|
|
|
|||
|
|
@ -148,8 +148,6 @@ enum GatewayStatusCode: Int {
|
|||
case userLoginExpair3 = 1001005
|
||||
// 权限不足
|
||||
case noAuthority = 500
|
||||
// 审核中
|
||||
case review = 201
|
||||
|
||||
/** ============== 未知错误 ============== */
|
||||
case unknownError = -9999
|
||||
|
|
|
|||
|
|
@ -31,9 +31,6 @@ enum UserAPI {
|
|||
/// 成员 APP 周期使用报告
|
||||
case phoneUsagePeriod(userId: String, groupKey: String, period: String)
|
||||
|
||||
/// 成员手机使用报告
|
||||
case phoneUsageReport(userId: String, groupKey: String)
|
||||
|
||||
/// 成员历史行程
|
||||
case phoneUsageTrips(userId: String, date: Int64)
|
||||
|
||||
|
|
@ -164,8 +161,6 @@ extension UserAPI: MultiTargetProtocol {
|
|||
return "mapi/phone/usage/today"
|
||||
case .phoneUsagePeriod:
|
||||
return "mapi/phone/usage/period"
|
||||
case .phoneUsageReport:
|
||||
return "mapi/phone/usage/report"
|
||||
case .phoneUsageTrips:
|
||||
return "mapi/phone/usage/trips"
|
||||
case .queryPhoneUsageIcon, .savePhoneUsageIcon:
|
||||
|
|
@ -229,7 +224,7 @@ extension UserAPI: MultiTargetProtocol {
|
|||
|
||||
var method: Moya.Method {
|
||||
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
|
||||
case .changePhone, .changeAvater:
|
||||
return .put
|
||||
|
|
@ -270,13 +265,6 @@ extension UserAPI: MultiTargetProtocol {
|
|||
]
|
||||
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):
|
||||
let params: Parameters = [
|
||||
"user_id": userId,
|
||||
|
|
|
|||
|
|
@ -18,12 +18,11 @@ import SwiftDate
|
|||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
private var didSetupPrivacyGrantedSDKs = false
|
||||
|
||||
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
|
||||
UnlockCountManager.shared.start(launchOptions: launchOptions)
|
||||
FontManager.registerCustomFonts()
|
||||
setupLocation()
|
||||
ApiManager.shared.setup()
|
||||
AppRouter.shared.setup()
|
||||
AppUnlockCoordinator.shared.start()
|
||||
|
|
@ -42,17 +41,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
IQKeyboardManager.shared.enableAutoToolbar = false
|
||||
IQKeyboardManager.shared.resignOnTouchOutside = true
|
||||
|
||||
// 微信
|
||||
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)
|
||||
AMapServices.shared().apiKey = AppSettings.kAppsAMapAppId
|
||||
#endif
|
||||
|
||||
// 个推
|
||||
#if !targetEnvironment(simulator)
|
||||
GeYanSdk.setPreLoginTimeout(10)
|
||||
GeYanSdk.setEloginTimeout(10)
|
||||
GeYanSdk.start(withAppId: AppSettings.kAppsGeTuiAppId) { isSuccess, error, gtcid in
|
||||
|
|
@ -63,19 +70,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
}
|
||||
#endif
|
||||
|
||||
// OpenIM
|
||||
GroupIMService.shared.initSDK()
|
||||
|
||||
// 友盟
|
||||
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 {
|
||||
|
|
@ -98,15 +94,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
print("📱 continue userActivity: \(userActivity.webpageURL?.absoluteString ?? "")")
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -21,30 +21,10 @@ extension DefaultsKeys {
|
|||
var isReLogin: DefaultsKey<Bool> { .init("isReLogin", defaultValue: false) }
|
||||
/// 显示引导页
|
||||
var isShowGuide: DefaultsKey<Bool> { .init("isShowGuide", defaultValue: true) }
|
||||
/// 启动页已同意服务协议及隐私政策
|
||||
var didAgreeLaunchPrivacy: DefaultsKey<Bool> { .init("didAgreeLaunchPrivacy", defaultValue: false) }
|
||||
/// 气泡持续时间
|
||||
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: []) }
|
||||
/// 等待执行服务端配对全删的账号
|
||||
|
|
@ -73,8 +53,6 @@ extension Notification.Name {
|
|||
static let resumePopupQueue = Notification.Name("resumePopupQueue")
|
||||
/// 终止弹窗队列(登录成功后)
|
||||
static let invalidatePopupQueue = Notification.Name("invalidatePopupQueue")
|
||||
/// 本机解锁次数变化
|
||||
static let unlockCountDidChange = Notification.Name("unlockCountDidChange")
|
||||
/// 本机应用配对数据变化
|
||||
static let appRestrictPairingDataDidChange = Notification.Name("appRestrictPairingDataDidChange")
|
||||
/// MQTT 应用锁定状态变化
|
||||
|
|
|
|||
|
|
@ -37,13 +37,6 @@
|
|||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>38.207.176.65</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>imapi.zuom8.cn</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
|
|
|
|||
|
|
@ -186,16 +186,16 @@ extension BaseViewController {
|
|||
|
||||
// MARK: - FLEX
|
||||
extension BaseViewController {
|
||||
private func setupFlex() {
|
||||
#if DEBUG
|
||||
private func setupFlex() {
|
||||
FLEXManager.shared.showExplorer()
|
||||
#endif
|
||||
}
|
||||
|
||||
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
||||
print("摇晃结束")
|
||||
setupFlex()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var brand: String = ""
|
||||
var model: String = ""
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class AppSettings: NSObject {
|
|||
}()
|
||||
|
||||
/// AppStore ID
|
||||
var appId: String { "" }
|
||||
var appId: String { "6790610409" }
|
||||
|
||||
/// UUID
|
||||
var uuid: String = ""
|
||||
|
|
|
|||
|
|
@ -75,8 +75,6 @@ enum Route: String {
|
|||
case itineraryTrace = "itineraryTrace"
|
||||
/// 今日轨迹详情
|
||||
case todayTrackDetail = "todayTrackDetail"
|
||||
/// 手机报告详情
|
||||
case phoneReportDetail = "phoneReportDetail"
|
||||
/// APP 使用报告
|
||||
case appUsageReport = "appUsageReport"
|
||||
/// 关于我们
|
||||
|
|
@ -378,11 +376,6 @@ extension AppRouter: AppRouterProtocol {
|
|||
TodayTrackDetailVC(userInfo: parameters)
|
||||
}
|
||||
|
||||
// MARK: - 手机报告详情
|
||||
AppRouter.register(Route.phoneReportDetail) { url, parameters in
|
||||
PhoneReportDetailVC(userInfo: parameters)
|
||||
}
|
||||
|
||||
// MARK: - APP 使用报告
|
||||
AppRouter.register(Route.appUsageReport) { _, parameters in
|
||||
AppUsageReportVC(userInfo: parameters)
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ enum MqttType: String, Codable {
|
|||
case bubbleQuit = "bubblequit" // 退出气泡
|
||||
case needTrack = "needtrack" // 追踪上报
|
||||
case emote = "emote" // 接收表情
|
||||
case phone = "phone" // 手机信息上报
|
||||
case phoneUsage = "phoneUsage" // 当前 App 使用次数上报
|
||||
case lockApp = "lockApp" // 远程锁 App
|
||||
case appUnlock = "appUnlock" // 远程解锁 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
|
||||
|
||||
/// 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() {
|
||||
super.init()
|
||||
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: - 连接
|
||||
|
|
@ -883,13 +781,11 @@ final class MQTTService: NSObject {
|
|||
func disconnect() {
|
||||
mqtt?.disconnect()
|
||||
isConnected = false
|
||||
invalidatePhoneReportTimer()
|
||||
}
|
||||
|
||||
// MARK: - 切换用户
|
||||
/// 更新 clientID 并重连(切换用户后调用)
|
||||
func updateClientID(_ newID: String) {
|
||||
resetPhoneReportState()
|
||||
clientID = newID
|
||||
disconnect()
|
||||
connect()
|
||||
|
|
@ -1064,206 +960,6 @@ final class MQTTService: NSObject {
|
|||
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) {
|
||||
if Thread.isMainThread {
|
||||
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
|
||||
|
|
@ -1309,8 +987,6 @@ extension MQTTService: CocoaMQTT5Delegate {
|
|||
}
|
||||
onConnected?()
|
||||
flushPendingLockAppsChangedEvents()
|
||||
reportPhoneUsageIfNeeded()
|
||||
reportPhoneIfNeeded()
|
||||
}
|
||||
|
||||
func mqtt5(_ mqtt5: CocoaMQTT5, didPublishMessage message: CocoaMQTT5Message, id: UInt16) {
|
||||
|
|
@ -1359,7 +1035,6 @@ extension MQTTService: CocoaMQTT5Delegate {
|
|||
|
||||
func mqtt5DidDisconnect(_ mqtt5: CocoaMQTT5, withError err: Error?) {
|
||||
isConnected = false
|
||||
invalidatePhoneReportTimer()
|
||||
print("MQTT5 disconnected: \(err?.localizedDescription ?? "")")
|
||||
onDisconnected?()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,10 +80,6 @@ final class MemberWeatherService {
|
|||
let snapshot = Self.snapshot(from: weather)
|
||||
self?.finish(key: key, result: .success(snapshot))
|
||||
} 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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@ import SwiftyUserDefaults
|
|||
import RxSwift
|
||||
|
||||
extension DefaultsKeys {
|
||||
// 接口环境 0:测试环境 1:正式环境
|
||||
var apiEnvKey: DefaultsKey<Int> { .init("ApiEnvKey", defaultValue: -99) }
|
||||
// 接口地址
|
||||
var apiServerURL: DefaultsKey<String> { .init("ApiServerURL", defaultValue: "") }
|
||||
|
||||
}
|
||||
|
||||
@objcMembers class URLManager: NSObject {
|
||||
|
|
@ -47,64 +44,27 @@ extension DefaultsKeys {
|
|||
}
|
||||
|
||||
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)
|
||||
openIM_API = "https://imapi.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) {
|
||||
// env
|
||||
if Defaults[\.apiEnvKey] != -99 {
|
||||
apiEnv = Defaults[\.apiEnvKey]
|
||||
}
|
||||
else {
|
||||
apiEnv = env
|
||||
}
|
||||
// api
|
||||
if Defaults[\.apiServerURL].isEmpty {
|
||||
apiServerURL = apiServerURL(apiEnv)
|
||||
} else {
|
||||
apiServerURL = Defaults[\.apiServerURL]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 接口地址
|
||||
func apiServerURL(_ env: Int) -> String {
|
||||
switch env {
|
||||
case 1: // 正式
|
||||
return "https://jsapi.zuom8.cn/"
|
||||
return "https://speed.batiao8.com/"
|
||||
case -1: // UAT
|
||||
return "https://jsapi.zuom8.cn/"
|
||||
return "https://speed.batiao8.com/"
|
||||
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 {
|
||||
var url = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !url.isEmpty else { return "" }
|
||||
|
|
@ -117,20 +77,8 @@ extension DefaultsKeys {
|
|||
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: - 大对象服务器地址
|
||||
func uploadServerURL() -> String {
|
||||
//#if DEBUG
|
||||
// return ""
|
||||
//#else
|
||||
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 {
|
||||
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?) {
|
||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
lastHeadPicValue = ""
|
||||
kf.cancelDownloadTask()
|
||||
image = HeadPic.placeholder
|
||||
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 !hasCachedImage {
|
||||
kf.cancelDownloadTask()
|
||||
kf.setImage(with: url, placeholder: nil, options: kNetworkImageFailureOptions)
|
||||
image = nil
|
||||
}
|
||||
kf.setImage(
|
||||
with: url,
|
||||
placeholder: hasCachedImage ? cached : nil,
|
||||
options: kNetworkImageFailureOptions
|
||||
)
|
||||
} else {
|
||||
kf.cancelDownloadTask()
|
||||
image = HeadPic.localImage(trimmed)
|
||||
image = cached
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,140 @@
|
|||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<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>
|
||||
<array>
|
||||
<dict>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import Kingfisher
|
||||
|
||||
final class GroupMemberListView: UIView {
|
||||
|
||||
|
|
@ -257,6 +258,7 @@ final class GroupMemberListCell: UICollectionViewCell {
|
|||
super.prepareForReuse()
|
||||
updateSOSBlink(false)
|
||||
bubbleImageView.isHidden = true
|
||||
avatarImageView.kf.cancelDownloadTask()
|
||||
}
|
||||
|
||||
func configure(model: GroupMemberModel, isCurrentUser: Bool, isSelected: Bool) {
|
||||
|
|
|
|||
|
|
@ -20,10 +20,8 @@ class GroupMemberView2: UIView {
|
|||
private var navBarHeightConstraint: NSLayoutConstraint?
|
||||
private var todayTrackTopToInteraction: NSLayoutConstraint?
|
||||
private var todayTrackTopToMember: NSLayoutConstraint?
|
||||
private var appUsageTopToPhoneReportConstraint: NSLayoutConstraint?
|
||||
private var appUsageTopToTodayTrackConstraint: NSLayoutConstraint?
|
||||
private var todayTrackBottomConstraint: NSLayoutConstraint?
|
||||
private var phoneReportBottomConstraint: NSLayoutConstraint?
|
||||
private var appUsageBottomConstraint: NSLayoutConstraint?
|
||||
private var isAppUsageVisible = false
|
||||
private var currentMemberModel: GroupMemberModel?
|
||||
|
|
@ -210,42 +208,32 @@ class GroupMemberView2: UIView {
|
|||
}
|
||||
}
|
||||
if isShowingOfflineOther {
|
||||
phoneReportView.configure(with: nil)
|
||||
appUsageView.configure(with: nil)
|
||||
applyFeatureAccessStates(appUsagePreview: nil)
|
||||
applyOfflineStayPoints()
|
||||
return
|
||||
}
|
||||
phoneReportView.configure(with: Self.phoneReportPreview(from: usage))
|
||||
let appUsagePreview = MemberAppUsagePreview(topApps: usage?.topApps ?? [])
|
||||
appUsageView.configure(with: appUsagePreview)
|
||||
applyFeatureAccessStates(appUsagePreview: appUsagePreview)
|
||||
applyStayPoints(usage?.stayPoints ?? [])
|
||||
}
|
||||
|
||||
var isPhoneReportDenied: Bool { phoneReportView.isDenied }
|
||||
var isAppUsageDenied: Bool { appUsageView.isDenied }
|
||||
var onPhoneReportRemind: (() -> Void)?
|
||||
var onAppUsageRemind: (() -> Void)?
|
||||
|
||||
private func applyFeatureAccessStates(appUsagePreview: MemberAppUsagePreview?) {
|
||||
guard currentMemberModel != nil else {
|
||||
phoneReportView.setAccessState(.available)
|
||||
appUsageView.setAccessState(.available)
|
||||
updateAppUsageVisibility(appUsagePreview != nil)
|
||||
return
|
||||
}
|
||||
let isSelf = isShowingCurrentUser
|
||||
let memberPrivilege = currentMemberModel?.extra.privilege ?? GroupPrivilege()
|
||||
let phoneState = Self.accessState(
|
||||
privilegeAllowed: isSelf || memberPrivilege.phone_report
|
||||
)
|
||||
let appUsageState = Self.accessState(
|
||||
privilegeAllowed: isSelf || memberPrivilege.app_usage_record
|
||||
)
|
||||
|
||||
phoneReportView.setAccessState(phoneState)
|
||||
phoneReportView.onDeniedAction = { [weak self] in self?.onPhoneReportRemind?() }
|
||||
appUsageView.setAccessState(appUsageState)
|
||||
appUsageView.onDeniedAction = { [weak self] in self?.onAppUsageRemind?() }
|
||||
let showUsage = appUsagePreview != nil || appUsageState != .available
|
||||
|
|
@ -399,16 +387,10 @@ class GroupMemberView2: UIView {
|
|||
}
|
||||
|
||||
private func updateFeatureVisibilityLayout() {
|
||||
let hidesPhoneReport = false
|
||||
let showsAppUsage = !hidesPhoneReport && isAppUsageVisible
|
||||
phoneReportView.isHidden = hidesPhoneReport
|
||||
appUsageView.isHidden = !showsAppUsage
|
||||
|
||||
appUsageTopToPhoneReportConstraint?.isActive = !hidesPhoneReport
|
||||
appUsageTopToTodayTrackConstraint?.isActive = hidesPhoneReport
|
||||
phoneReportBottomConstraint?.isActive = !hidesPhoneReport && !isAppUsageVisible
|
||||
appUsageBottomConstraint?.isActive = showsAppUsage
|
||||
todayTrackBottomConstraint?.isActive = hidesPhoneReport
|
||||
appUsageView.isHidden = !isAppUsageVisible
|
||||
appUsageTopToTodayTrackConstraint?.isActive = true
|
||||
appUsageBottomConstraint?.isActive = isAppUsageVisible
|
||||
todayTrackBottomConstraint?.isActive = !isAppUsageVisible
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
|
|
@ -472,23 +454,6 @@ class GroupMemberView2: UIView {
|
|||
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]) {
|
||||
stayNumLab.text = "\(stayPoints.count)"
|
||||
guard let latest = stayPoints.max(by: {
|
||||
|
|
@ -619,28 +584,14 @@ class GroupMemberView2: UIView {
|
|||
.edgesHorzontal()
|
||||
todayTrackTopToMember = todayTrackView.jh_pinEdge(.top, toEdge: .bottom, of: memberView, offset: 12)
|
||||
|
||||
scrollContentView.addSubview(phoneReportView)
|
||||
phoneReportView.layoutChain
|
||||
.topToBottomOfView(todayTrackView, offset: 15)
|
||||
.edgesHorzontal()
|
||||
|
||||
scrollContentView.addSubview(appUsageView)
|
||||
appUsageView.layoutChain
|
||||
.edgesHorzontal()
|
||||
appUsageTopToPhoneReportConstraint = appUsageView.topAnchor.constraint(
|
||||
equalTo: phoneReportView.bottomAnchor,
|
||||
constant: 15
|
||||
)
|
||||
appUsageTopToPhoneReportConstraint?.isActive = true
|
||||
appUsageTopToTodayTrackConstraint = appUsageView.topAnchor.constraint(
|
||||
equalTo: todayTrackView.bottomAnchor,
|
||||
constant: 15
|
||||
)
|
||||
|
||||
phoneReportBottomConstraint = phoneReportView.bottomAnchor.constraint(
|
||||
equalTo: scrollContentView.bottomAnchor,
|
||||
constant: -(49 + kSafeBottomMargin)
|
||||
)
|
||||
appUsageTopToTodayTrackConstraint?.isActive = true
|
||||
appUsageBottomConstraint = appUsageView.bottomAnchor.constraint(
|
||||
equalTo: scrollContentView.bottomAnchor,
|
||||
constant: -(49 + kSafeBottomMargin)
|
||||
|
|
@ -649,7 +600,7 @@ class GroupMemberView2: UIView {
|
|||
equalTo: scrollContentView.bottomAnchor,
|
||||
constant: -(49 + kSafeBottomMargin)
|
||||
)
|
||||
phoneReportBottomConstraint?.isActive = true
|
||||
todayTrackBottomConstraint?.isActive = true
|
||||
|
||||
return view
|
||||
}()
|
||||
|
|
@ -1191,8 +1142,7 @@ class GroupMemberView2: UIView {
|
|||
return label
|
||||
}()
|
||||
|
||||
// MARK: - 手机报告 / APP使用记录
|
||||
lazy var phoneReportView = MemberPhoneReportView()
|
||||
// MARK: - APP使用记录
|
||||
lazy var appUsageView: MemberAppUsageView = {
|
||||
let view = MemberAppUsageView()
|
||||
view.isHidden = true
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ class HomeViewController: BaseViewController {
|
|||
private var groupInfoRequestID = UUID()
|
||||
private var pendingGroupDataChanges: [GroupDataChange] = []
|
||||
private var hasAppeared = false
|
||||
private var didRequestWhenInUseLocation = false
|
||||
|
||||
private struct GroupDataChange {
|
||||
let groupKey: String
|
||||
|
|
@ -123,12 +124,9 @@ class HomeViewController: BaseViewController {
|
|||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
UnlockCountManager.shared.refreshAuthorizationAndMonitoring()
|
||||
MQTTService.shared.applicationDidBecomeActive()
|
||||
rootView.groupMemberView.refreshLocalDeviceInfoIfNeeded()
|
||||
|
||||
let manager = AuthorizeManager.manager(type: .locationAlways)
|
||||
rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized
|
||||
requestWhenInUseLocationIfNeeded()
|
||||
refreshLocationWarning()
|
||||
refreshPigeonInbox()
|
||||
queryUnlockRequests()
|
||||
if hasAppeared {
|
||||
|
|
@ -143,6 +141,21 @@ class HomeViewController: BaseViewController {
|
|||
// 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 位置上报
|
||||
private let geocoder = CLGeocoder()
|
||||
private var lastGeocodedCoord: CLLocationCoordinate2D?
|
||||
|
|
@ -341,13 +354,6 @@ class HomeViewController: BaseViewController {
|
|||
self?.openMessagePopView()
|
||||
}).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 报告二级页
|
||||
rootView.groupMemberView.appUsageView.rx.tapGesture.subscribe(onNext: { [weak self] _ in
|
||||
guard let self else { return }
|
||||
|
|
@ -355,9 +361,6 @@ class HomeViewController: BaseViewController {
|
|||
self.openAppUsageReport()
|
||||
}).disposed(by: disposeBag)
|
||||
|
||||
rootView.groupMemberView.onPhoneReportRemind = { [weak self] in
|
||||
self?.remindSelectedMember(content: "请开启手机报告隐私设置")
|
||||
}
|
||||
rootView.groupMemberView.onAppUsageRemind = { [weak self] in
|
||||
self?.remindSelectedMember(content: "请开启App使用记录隐私设置")
|
||||
}
|
||||
|
|
@ -677,11 +680,6 @@ class HomeViewController: BaseViewController {
|
|||
rootView.groupMemberView.groupNameLab.text = viewModel.groupName.isEmpty
|
||||
? "我的圈子"
|
||||
: viewModel.groupName
|
||||
MQTTService.shared.updatePhoneReportContext(
|
||||
groupKey: hasRequestedGroup ? requestedGroupKey : "",
|
||||
location: phoneReportLocation
|
||||
)
|
||||
|
||||
let pickerMembers = isSwitchingGroup ? nil : viewModel.memberList
|
||||
GroupListPopView.updateIfVisible(
|
||||
groupModel: model,
|
||||
|
|
@ -859,18 +857,6 @@ class HomeViewController: BaseViewController {
|
|||
.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 {
|
||||
"\(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() {
|
||||
let members = viewModel.memberList.map { $0.toJSON() }
|
||||
var userInfo: [String: Any] = [
|
||||
|
|
@ -2003,10 +1977,6 @@ extension HomeViewController: MAMapViewDelegate {
|
|||
// 地图标注
|
||||
let isFirstFix = lastLocation == nil
|
||||
lastLocation = location
|
||||
MQTTService.shared.updatePhoneReportContext(
|
||||
groupKey: viewModel.groupModel?.default_group_key,
|
||||
location: location
|
||||
)
|
||||
if isFirstFix {
|
||||
reportCurrentLocationIfNeeded()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,20 +135,23 @@ class HomeViewModel {
|
|||
/// 标记成员离线(保留 last_active_time 用于显示"X分钟前")
|
||||
func setMemberOffline(userId: String) {
|
||||
guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return }
|
||||
var updated = memberList
|
||||
updated[idx].is_online = false
|
||||
updateMembers(updated)
|
||||
guard memberList[idx].is_online else { return }
|
||||
memberList[idx].is_online = false
|
||||
updateMembers(memberList)
|
||||
}
|
||||
|
||||
/// MQTT 位置更新后刷新成员的地址、电量、在线状态
|
||||
/// MQTT 位置更新后刷新成员的地址、电量、在线状态。
|
||||
/// 列表 cell 不展示这些字段,在线状态没变时不要整表 reload。
|
||||
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 }
|
||||
var updated = memberList
|
||||
updated[idx].last_position = "\(lat):\(lon):\(address)"
|
||||
updated[idx].battery = battery
|
||||
updated[idx].is_online = isOnline
|
||||
updated[idx].last_active_time = lastUpdateTime
|
||||
updateMembers(updated)
|
||||
let onlineChanged = memberList[idx].is_online != isOnline
|
||||
memberList[idx].last_position = "\(lat):\(lon):\(address)"
|
||||
memberList[idx].battery = battery
|
||||
memberList[idx].is_online = isOnline
|
||||
memberList[idx].last_active_time = lastUpdateTime
|
||||
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 {
|
||||
|
||||
private let showVersion = 1
|
||||
private var privacyConsentView: LaunchPrivacyConsentView?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Do any additional setup after loading the view.
|
||||
view.backgroundColor = .white
|
||||
setupLayout()
|
||||
if Defaults[\.didAgreeLaunchPrivacy] {
|
||||
proceedAfterPrivacyAgreed()
|
||||
} else {
|
||||
showPrivacyConsentIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func proceedAfterPrivacyAgreed() {
|
||||
AppDelegate.shared.setupPrivacyGrantedSDKs()
|
||||
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() {
|
||||
SystemService.userConfig().subscribe(onNext: { response in
|
||||
|
|
@ -45,52 +92,11 @@ class LaunchViewController: BaseViewController {
|
|||
DLAlert.show(title: error.localizedDescription,
|
||||
defaultTitle: "重试") { [weak self] in
|
||||
guard let this = self else { return }
|
||||
// #if DEBUG || AdHoc
|
||||
// this.showServerSwitchAlert()
|
||||
// #else
|
||||
this.getUserConfig()
|
||||
// #endif
|
||||
}
|
||||
}).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) {
|
||||
AppDelegate.shared.showMainViewController()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -457,7 +457,7 @@ class LoginView: UIView {
|
|||
let btn = UIButton(type: .custom)
|
||||
btn.setImage(UIImage(named: "Login/checkbox"), for: .normal)
|
||||
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
|
||||
}()
|
||||
|
||||
|
|
|
|||
|
|
@ -123,11 +123,19 @@ final class LoginViewModel: BaseViewModel {
|
|||
#endif
|
||||
|
||||
func performWechatLogin() {
|
||||
guard WXApi.isWXAppInstalled() else {
|
||||
DLToast.showError(text: "请先安装微信")
|
||||
return
|
||||
}
|
||||
NotificationCenter.default.post(name: .pausePopupQueue, object: nil)
|
||||
let req = SendAuthReq()
|
||||
req.scope = "snsapi_userinfo"
|
||||
req.state = "login"
|
||||
WXApi.send(req)
|
||||
WXApi.send(req) { success in
|
||||
if !success {
|
||||
DLToast.showError(text: "无法打开微信")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func performAppleLogin() {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import RxCocoa
|
|||
final class AboutVC: BaseViewController {
|
||||
|
||||
private var rootView: AboutView!
|
||||
private var logoTapCount = 0
|
||||
private var logoTapResetWork: DispatchWorkItem?
|
||||
|
||||
override func loadView() {
|
||||
rootView = AboutView(frame: UIScreen.main.bounds)
|
||||
|
|
@ -28,9 +26,6 @@ final class AboutVC: BaseViewController {
|
|||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
let logoTap = UITapGestureRecognizer(target: self, action: #selector(handleLogoTap))
|
||||
rootView.logoImage.addGestureRecognizer(logoTap)
|
||||
|
||||
rootView.feedbackBtn.rx.controlEvent(.touchUpInside)
|
||||
.subscribe(onNext: { _ in
|
||||
AppRouter.push(Route.feedback)
|
||||
|
|
@ -62,60 +57,6 @@ final class AboutVC: BaseViewController {
|
|||
.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() {
|
||||
// Bundle 未配置 appId 时给提示;有则跳转写评价页
|
||||
let appId = AppSettings.shared.appId
|
||||
|
|
|
|||
|
|
@ -8,14 +8,11 @@ import RxSwift
|
|||
import Kingfisher
|
||||
|
||||
enum PrivacySettingItem: CaseIterable {
|
||||
case phoneReport
|
||||
case groupLock
|
||||
case pigeonMessage
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .phoneReport:
|
||||
return "手机报告"
|
||||
case .groupLock:
|
||||
return "一键锁机"
|
||||
case .pigeonMessage:
|
||||
|
|
@ -25,8 +22,6 @@ enum PrivacySettingItem: CaseIterable {
|
|||
|
||||
var imageName: String {
|
||||
switch self {
|
||||
case .phoneReport:
|
||||
return "PrivacySettings/phone_report"
|
||||
case .groupLock:
|
||||
return "PrivacySettings/group_lock"
|
||||
case .pigeonMessage:
|
||||
|
|
@ -36,8 +31,6 @@ enum PrivacySettingItem: CaseIterable {
|
|||
|
||||
var apiKey: String {
|
||||
switch self {
|
||||
case .phoneReport:
|
||||
return "phone_report"
|
||||
case .groupLock:
|
||||
return "app_lock"
|
||||
case .pigeonMessage:
|
||||
|
|
@ -208,8 +201,6 @@ final class PrivacySettingsViewController: BaseViewController {
|
|||
let previous = groups[index].privilege
|
||||
var updated = previous
|
||||
switch item {
|
||||
case .phoneReport:
|
||||
updated.phone_report = isOn
|
||||
case .groupLock:
|
||||
updated.app_lock = isOn
|
||||
case .pigeonMessage:
|
||||
|
|
@ -266,7 +257,6 @@ final class PrivacySettingsView: UIView {
|
|||
private let scrollView = UIScrollView()
|
||||
private let contentView = UIView()
|
||||
private let groupSelectorView = PrivacyGroupSelectorView()
|
||||
private let phoneReportRow = PrivacySettingRow(item: .phoneReport)
|
||||
private let groupLockRow = PrivacySettingRow(item: .groupLock)
|
||||
private let pigeonMessageRow = PrivacySettingRow(item: .pigeonMessage)
|
||||
private let footerLabel = UILabel()
|
||||
|
|
@ -286,13 +276,12 @@ final class PrivacySettingsView: UIView {
|
|||
}
|
||||
|
||||
func apply(_ privilege: GroupPrivilege) {
|
||||
phoneReportRow.setOn(privilege.phone_report)
|
||||
groupLockRow.setOn(privilege.app_lock)
|
||||
pigeonMessageRow.setOn(privilege.pigeon_mail)
|
||||
}
|
||||
|
||||
func setSettingsEnabled(_ enabled: Bool) {
|
||||
[phoneReportRow, groupLockRow, pigeonMessageRow].forEach {
|
||||
[groupLockRow, pigeonMessageRow].forEach {
|
||||
$0.setSwitchEnabled(enabled)
|
||||
}
|
||||
}
|
||||
|
|
@ -318,10 +307,6 @@ final class PrivacySettingsView: UIView {
|
|||
|
||||
contentView.addSubview(groupSelectorView)
|
||||
|
||||
let shareHeader = makeSectionHeader(title: "圈子中可以共享的数据")
|
||||
contentView.addSubview(shareHeader)
|
||||
contentView.addSubview(phoneReportRow)
|
||||
|
||||
let ownerHeader = makeSectionHeader(title: "圈主可以对你使用的功能")
|
||||
contentView.addSubview(ownerHeader)
|
||||
contentView.addSubview(groupLockRow)
|
||||
|
|
@ -357,20 +342,8 @@ final class PrivacySettingsView: UIView {
|
|||
.right(15)
|
||||
.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
|
||||
.topToBottomOfView(phoneReportRow, offset: 18)
|
||||
.topToBottomOfView(groupSelectorView, offset: 20)
|
||||
.left(17)
|
||||
.right(15)
|
||||
.height(24)
|
||||
|
|
@ -391,9 +364,6 @@ final class PrivacySettingsView: UIView {
|
|||
groupSelectorView.onSelectionChanged = { [weak self] in
|
||||
self?.onGroupChanged?()
|
||||
}
|
||||
phoneReportRow.onToggle = { [weak self] isOn in
|
||||
self?.onToggle?(.phoneReport, isOn)
|
||||
}
|
||||
groupLockRow.onToggle = { [weak self] isOn in
|
||||
self?.onToggle?(.groupLock, isOn)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,14 +62,6 @@ struct UserService {
|
|||
.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> {
|
||||
let api = UserAPI.phoneUsageTrips(userId: userId, date: date).multiTarget
|
||||
|
|
|
|||
Loading…
Reference in New Issue