1、手机报告删除(MQTT上报)

2、服务协议及隐私政策弹窗
3、定位权限申请放到首页
This commit is contained in:
linshujie 2026-09-22 11:55:59 +08:00
parent 25aac678ac
commit fb4a63d10b
31 changed files with 532 additions and 3300 deletions

View File

@ -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;

View File

@ -148,8 +148,6 @@ enum GatewayStatusCode: Int {
case userLoginExpair3 = 1001005
// 权限不足
case noAuthority = 500
// 审核中
case review = 201
/** ============== 未知错误 ============== */
case unknownError = -9999

View File

@ -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,

View File

@ -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 {

View File

@ -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 应用锁定状态变化

View File

@ -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>

View File

@ -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查看大图

View File

@ -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 = ""

View File

@ -29,7 +29,7 @@ class AppSettings: NSObject {
}()
/// AppStore ID
var appId: String { "" }
var appId: String { "6790610409" }
/// UUID
var uuid: String = ""

View File

@ -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)

View File

@ -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?()
}

View File

@ -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))
}
}

View File

@ -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]
}
apiServerURL = apiServerURL(apiEnv)
}
// 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
}
}

View File

@ -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)")
}
}

View File

@ -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) {
kf.cancelDownloadTask()
kf.setImage(with: url, placeholder: nil, options: kNetworkImageFailureOptions)
if !hasCachedImage {
kf.cancelDownloadTask()
image = nil
}
kf.setImage(
with: url,
placeholder: hasCachedImage ? cached : nil,
options: kNetworkImageFailureOptions
)
} else {
kf.cancelDownloadTask()
image = HeadPic.localImage(trimmed)
image = cached
}
}
}

View File

@ -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>

View File

@ -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) {

View File

@ -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

View File

@ -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()
}

View File

@ -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)
}
}
/// 成员是否存在

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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")
}
}

View File

@ -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)分钟"
}
}

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -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
}()

View File

@ -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() {

View File

@ -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

View File

@ -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)
}

View File

@ -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