diff --git a/AppRestrictShared/AppRestrictShared.swift b/AppRestrictShared/AppRestrictShared.swift index d545b0c3..16712446 100644 --- a/AppRestrictShared/AppRestrictShared.swift +++ b/AppRestrictShared/AppRestrictShared.swift @@ -88,6 +88,15 @@ enum AppRestrictTokenCodec { try? PropertyListDecoder().decode(ApplicationToken.self, from: data) } + static func decodeBase64(_ value: String) -> ApplicationToken? { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, + let data = Data(base64Encoded: text, options: .ignoreUnknownCharacters) else { + return nil + } + return decode(data) + } + static func encodeSelection(_ selection: FamilyActivitySelection) -> Data? { try? PropertyListEncoder().encode(selection) } @@ -202,6 +211,22 @@ enum AppRestrictSharedStore { links = links.filter { $0.tokenData != data } } + static var hasPairingData: Bool { + let storedSelection = selection + return !storedSelection.applicationTokens.isEmpty + || !storedSelection.categoryTokens.isEmpty + || !storedSelection.webDomainTokens.isEmpty + || !enabledTokens.isEmpty + || !links.isEmpty + } + + static func clearPairingData() { + selection = FamilyActivitySelection() + enabledTokens = [] + links = [] + clearShield() + } + static func applyShield(for tokens: Set) { let store = AppRestrictShared.makeStore() if tokens.isEmpty { @@ -237,10 +262,15 @@ enum AppRestrictSharedStore { } } + static func loadShieldDisplayImage() -> UIImage? { + // 系统把 icon 画在约 100pt 方格里;画布按这个尺寸、圆角 30,屏幕上看才是 30。 + loadShieldImage()?.appRestrictShieldIcon(size: 100, cornerRadius: 30) + } + static func saveCustomImage(_ image: UIImage) -> Bool { guard let url = AppRestrictShared.customImageURL else { return false } let resized = image.appRestrictResized(maxSide: 720) - guard let data = resized.jpegData(compressionQuality: 0.82) else { return false } + guard let data = resized.pngData() else { return false } do { try data.write(to: url, options: .atomic) return true @@ -250,7 +280,7 @@ enum AppRestrictSharedStore { } } -private extension UIImage { +extension UIImage { func appRestrictResized(maxSide: CGFloat) -> UIImage { let maxCurrent = max(size.width, size.height) guard maxCurrent > maxSide, maxCurrent > 0 else { return self } @@ -261,4 +291,20 @@ private extension UIImage { draw(in: CGRect(origin: .zero, size: newSize)) } } + + func appRestrictShieldIcon(size: CGFloat, cornerRadius: CGFloat) -> UIImage { + let canvas = CGSize(width: size, height: size) + let format = UIGraphicsImageRendererFormat() + format.opaque = false + format.scale = UIScreen.main.scale + let renderer = UIGraphicsImageRenderer(size: canvas, format: format) + return renderer.image { _ in + let rect = CGRect(origin: .zero, size: canvas) + let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius) + path.addClip() + UIColor.white.setFill() + path.fill() + draw(in: rect) + } + } } diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index c1d77aad..09aa26fa 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -342,6 +342,7 @@ 55B218B13024B00100784722 /* FeatureIntroVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218B13024B00100784721 /* FeatureIntroVC.swift */; }; 55B218B13024B00100784724 /* FeatureIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218B13024B00100784723 /* FeatureIntroView.swift */; }; 55B219A2302A000100784751 /* UnlockRequestPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219A1302A000100784750 /* UnlockRequestPopView.swift */; }; + 55B250023050000100784902 /* LockedAppPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B250013050000100784901 /* LockedAppPopView.swift */; }; 55B219B2302A000100784761 /* ReceiveMessagePopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219B1302A000100784760 /* ReceiveMessagePopView.swift */; }; 55B219B4302A000100784763 /* ReceiveMessageReportPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219B3302A000100784762 /* ReceiveMessageReportPopView.swift */; }; 55B219C13024C00100784722 /* BubbleHeroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219C13024C00100784721 /* BubbleHeroView.swift */; }; @@ -787,6 +788,7 @@ 55B218B13024B00100784721 /* FeatureIntroVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIntroVC.swift; sourceTree = ""; }; 55B218B13024B00100784723 /* FeatureIntroView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIntroView.swift; sourceTree = ""; }; 55B219A1302A000100784750 /* UnlockRequestPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockRequestPopView.swift; sourceTree = ""; }; + 55B250013050000100784901 /* LockedAppPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockedAppPopView.swift; sourceTree = ""; }; 55B219B1302A000100784760 /* ReceiveMessagePopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiveMessagePopView.swift; sourceTree = ""; }; 55B219B3302A000100784762 /* ReceiveMessageReportPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiveMessageReportPopView.swift; sourceTree = ""; }; 55B219C13024C00100784721 /* BubbleHeroView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubbleHeroView.swift; sourceTree = ""; }; @@ -1324,6 +1326,7 @@ 55B222000000000100784830 /* AddEmojiPopView.swift */, 55B222020000000100784832 /* AddMessagePopView.swift */, 55B219A1302A000100784750 /* UnlockRequestPopView.swift */, + 55B250013050000100784901 /* LockedAppPopView.swift */, 55B219B1302A000100784760 /* ReceiveMessagePopView.swift */, 55B219B3302A000100784762 /* ReceiveMessageReportPopView.swift */, 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */, @@ -2502,6 +2505,7 @@ 30EFF3A82FD7C6A400EB35D4 /* GroupSettingViewModel.swift in Sources */, 55B2179130217D6600784774 /* HomeView2.swift in Sources */, 55B219A2302A000100784751 /* UnlockRequestPopView.swift in Sources */, + 55B250023050000100784902 /* LockedAppPopView.swift in Sources */, 55B219B2302A000100784761 /* ReceiveMessagePopView.swift in Sources */, 55B219B4302A000100784763 /* ReceiveMessageReportPopView.swift in Sources */, 55B219C2302A000100784771 /* PigeonMessageVC.swift in Sources */, diff --git a/QuickLocation/API/APIProvider.swift b/QuickLocation/API/APIProvider.swift index 0a8af443..5f493708 100644 --- a/QuickLocation/API/APIProvider.swift +++ b/QuickLocation/API/APIProvider.swift @@ -148,7 +148,10 @@ enum GatewayStatusCode: Int { case noAuthority = 500 // 审核中 case review = 201 + /** ============== 业务 ============== */ + // 需要开通vip + case needVip = 20000 // 您创建的圈子个数已达上限,请升级会员等级 case groupLimit = 20009 diff --git a/QuickLocation/API/UserAPI.swift b/QuickLocation/API/UserAPI.swift index 9ab53e04..f7962508 100644 --- a/QuickLocation/API/UserAPI.swift +++ b/QuickLocation/API/UserAPI.swift @@ -18,7 +18,7 @@ enum UserAPI { case login(type: String, bind: String, data: [String: Any]) - + /// 用户信息 case userInfo @@ -33,7 +33,43 @@ enum UserAPI { /// 成员手机使用报告 case phoneUsageReport(userId: String, groupKey: String) - + + /// 成员历史行程 + case phoneUsageTrips(userId: String, date: Int64) + + /// 查询应用图标是否已存在 + case queryPhoneUsageIcon(package: String) + + /// 登记应用图标 + case savePhoneUsageIcon(package: String, icon: String) + + /// 上报本机配对应用 + case phoneLockApp(token: String, icon: String) + + /// 成员可锁应用列表 + case phoneLockApps(userId: String, groupKey: String) + + /// 删除本机配对应用;空 token 数组表示全部删除 + case phoneLockAppsDelete(os: String, tokens: [String]) + + /// 锁定成员应用 + case phoneLock(os: String, groupKey: String, userId: String, tokens: [String], iconIndex: Int, message: String) + + /// 查询自己是否被锁 + case phoneLocked(os: String) + + /// 发起解锁请求 + case requestPhoneUnlock(os: String, groupKey: String, tokens: [String]) + + /// 拉取解锁请求列表 + case phoneUnlockRequests(os: String) + + /// 解锁 + case phoneUnlock(os: String, groupKey: String, userId: String, tokens: [String]) + + /// 查询本机离线期间是否允许解锁 + case phoneUnlockAllow(os: String) + /// 用户IM Token case imToken @@ -43,8 +79,10 @@ enum UserAPI { /// 更换手机号 case changePhone(timestamp: String, phone: String, code: String) - /// 设置头像 - case setHeadPic(index: Int) + /// 设置头像(预设序号或上传后的图片 URL) + case setHeadPic(headPic: String) + + case changeAvater(url: String) /// 昵称 case setNickName(nick: String) @@ -116,11 +154,31 @@ extension UserAPI: MultiTargetProtocol { return "mapi/phone/usage/period" case .phoneUsageReport: return "mapi/phone/usage/report" + case .phoneUsageTrips: + return "mapi/phone/usage/trips" + case .queryPhoneUsageIcon, .savePhoneUsageIcon: + return "mapi/phone/usage/icon" + case .phoneLockApp: + return "mapi/phone/lock/app" + case .phoneLockApps: + return "mapi/phone/lock/apps" + case .phoneLockAppsDelete: + return "mapi/phone/lock/apps/delete" + case .phoneLock: + return "mapi/phone/lock" + case .phoneLocked: + return "mapi/phone/locked" + case .requestPhoneUnlock, .phoneUnlockRequests: + return "mapi/phone/unlock/request" + case .phoneUnlock: + return "mapi/phone/unlock" + case .phoneUnlockAllow: + return "mapi/phone/unlock/allow" case .imToken: return "mapi/openim/user/token/get" case .signInInfo: return "mapi/user/signin" - case .changePhone: + case .changePhone, .changeAvater: return "api/user" case .setHeadPic: return "mapi/user/setheadpic" @@ -129,7 +187,7 @@ extension UserAPI: MultiTargetProtocol { case .setMood: return "mapi/user/setmood" case .setGender: - return "api/user" + return "mapi/user/setsex" case .emergencyContact: return "mapi/user/emergencycontact" case .deleteAccount: @@ -155,9 +213,9 @@ extension UserAPI: MultiTargetProtocol { var method: Moya.Method { switch self { - case .userInfo, .userStatus, .phoneUsageToday, .phoneUsagePeriod, .phoneUsageReport, .signInInfo, .notice, .followList, .relations: + case .userInfo, .userStatus, .phoneUsageToday, .phoneUsagePeriod, .phoneUsageReport, .phoneUsageTrips, .queryPhoneUsageIcon, .phoneLockApps, .phoneLocked, .phoneUnlockRequests, .phoneUnlockAllow, .signInInfo, .notice, .followList, .relations: return .get - case .changePhone, .setGender: + case .changePhone, .setGender, .changeAvater: return .put case .deleteAccount: return .delete @@ -174,7 +232,7 @@ extension UserAPI: MultiTargetProtocol { params["bind"] = bind params["data"] = data return .requestParameters(parameters: params, encoding: JSONEncoding()) - + case .userInfo: return .requestParameters(parameters: Parameters(), encoding: URLEncoding()) @@ -202,16 +260,119 @@ extension UserAPI: MultiTargetProtocol { "group_key": groupKey ] return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + + case let .phoneUsageTrips(userId, date): + let params: Parameters = [ + "user_id": userId, + "date": NSNumber(value: date), + "simplify": "true", + "include_events": "false" + ] + return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + + case let .queryPhoneUsageIcon(package): + return .requestParameters(parameters: ["package": package], encoding: URLEncoding.queryString) + + case let .savePhoneUsageIcon(package, icon): + return .requestParameters( + parameters: ["package": package, "icon": icon], + encoding: JSONEncoding() + ) + + case let .phoneLockApp(token, icon): + return .requestParameters( + parameters: [ + "os": "ios", + "token": token, + "icon": icon + ], + encoding: JSONEncoding() + ) + + case let .phoneLockApps(userId, groupKey): + return .requestParameters( + parameters: [ + "user_id": userId, + "group_key": groupKey + ], + encoding: URLEncoding.queryString + ) + + case let .phoneLockAppsDelete(os, tokens): + return .requestParameters( + parameters: [ + "os": os, + "token": tokens + ], + encoding: JSONEncoding() + ) + + case let .phoneLock(os, groupKey, userId, tokens, iconIndex, message): + return .requestParameters( + parameters: [ + "os": os, + "group_key": groupKey, + "user_id": userId, + "token": tokens, + "icon_index": iconIndex, + "message": message + ], + encoding: JSONEncoding() + ) + + case let .phoneLocked(os): + return .requestParameters( + parameters: ["os": os], + encoding: URLEncoding.queryString + ) + + case let .requestPhoneUnlock(os, groupKey, tokens): + return .requestParameters( + parameters: [ + "os": os, + "group_key": groupKey, + "token": tokens + ], + encoding: JSONEncoding() + ) + + case let .phoneUnlockRequests(os): + return .requestParameters( + parameters: ["os": os], + encoding: URLEncoding.queryString + ) + + case let .phoneUnlock(os, groupKey, userId, tokens): + return .requestParameters( + parameters: [ + "os": os, + "group_key": groupKey, + "user_id": userId, + "token": tokens + ], + encoding: JSONEncoding() + ) + + case let .phoneUnlockAllow(os): + return .requestParameters( + parameters: ["os": os], + encoding: URLEncoding.queryString + ) case .imToken: var params = Parameters() params["platform_id"] = 1 params["force"] = true return .requestParameters(parameters: params, encoding: JSONEncoding()) - + case .signInInfo: return .requestParameters(parameters: Parameters(), encoding: URLEncoding()) + case let .changeAvater(url): + var params = Parameters() + params["avater"] = url + return .requestParameters(parameters: params, encoding: JSONEncoding()) + case let .changePhone(timestamp, phone, code): var params = Parameters() params["phone_timestamp"] = timestamp @@ -219,9 +380,9 @@ extension UserAPI: MultiTargetProtocol { params["phone_code"] = code return .requestParameters(parameters: params, encoding: JSONEncoding()) - case let .setHeadPic(index): + case let .setHeadPic(headPic): var params = Parameters() - params["head_pic_index"] = index + params["head_pic_index"] = headPic return .requestParameters(parameters: params, encoding: JSONEncoding()) case let .setNickName(nick): diff --git a/QuickLocation/AppDelegate.swift b/QuickLocation/AppDelegate.swift index 698f9128..619fb277 100644 --- a/QuickLocation/AppDelegate.swift +++ b/QuickLocation/AppDelegate.swift @@ -26,6 +26,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { setupLocation() ApiManager.shared.setup() AppRouter.shared.setup() + AppUnlockCoordinator.shared.start() + if #available(iOS 16.0, *) { + AppRestrictManager.shared.startPermissionMonitoring() + } return true } diff --git a/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@2x.png b/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@2x.png index 627eea7e..7a3a4eb0 100644 Binary files a/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@2x.png and b/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@3x.png b/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@3x.png index 39623d54..23a41677 100644 Binary files a/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@3x.png and b/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/Contents.json b/QuickLocation/Assets.xcassets/Home/HistoryTrack/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/HistoryTrack/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/clock.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/HistoryTrack/clock.imageset/Contents.json new file mode 100644 index 00000000..e9297791 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/HistoryTrack/clock.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "clock@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "clock@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/clock.imageset/clock@2x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/clock.imageset/clock@2x.png new file mode 100644 index 00000000..b518bd6c Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/clock.imageset/clock@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/clock.imageset/clock@3x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/clock.imageset/clock@3x.png new file mode 100644 index 00000000..bb319db0 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/clock.imageset/clock@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_next.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_next.imageset/Contents.json new file mode 100644 index 00000000..ba0cb113 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_next.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "Group 1901@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "Group 1901@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_next.imageset/Group 1901@2x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_next.imageset/Group 1901@2x.png new file mode 100644 index 00000000..576ba460 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_next.imageset/Group 1901@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_next.imageset/Group 1901@3x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_next.imageset/Group 1901@3x.png new file mode 100644 index 00000000..c0f14313 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_next.imageset/Group 1901@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_prev.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_prev.imageset/Contents.json new file mode 100644 index 00000000..51a797ad --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_prev.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "Group 1900@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "Group 1900@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_prev.imageset/Group 1900@2x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_prev.imageset/Group 1900@2x.png new file mode 100644 index 00000000..ccf12a7b Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_prev.imageset/Group 1900@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_prev.imageset/Group 1900@3x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_prev.imageset/Group 1900@3x.png new file mode 100644 index 00000000..2ef5e382 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/date_prev.imageset/Group 1900@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/end.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/HistoryTrack/end.imageset/Contents.json new file mode 100644 index 00000000..5b1849e5 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/HistoryTrack/end.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "组 48355@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "组 48355@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/end.imageset/组 48355@2x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/end.imageset/组 48355@2x.png new file mode 100644 index 00000000..dbbef09f Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/end.imageset/组 48355@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/end.imageset/组 48355@3x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/end.imageset/组 48355@3x.png new file mode 100644 index 00000000..c0c50b9b Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/end.imageset/组 48355@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/header_bg.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/HistoryTrack/header_bg.imageset/Contents.json new file mode 100644 index 00000000..5da9d407 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/HistoryTrack/header_bg.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "组 46168@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "组 46168@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/header_bg.imageset/组 46168@2x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/header_bg.imageset/组 46168@2x.png new file mode 100644 index 00000000..4a90ad45 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/header_bg.imageset/组 46168@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/header_bg.imageset/组 46168@3x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/header_bg.imageset/组 46168@3x.png new file mode 100644 index 00000000..3c2bff93 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/header_bg.imageset/组 46168@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/pause.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/HistoryTrack/pause.imageset/Contents.json new file mode 100644 index 00000000..f1e1b8a9 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/HistoryTrack/pause.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "暂停@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "暂停@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/pause.imageset/暂停@2x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/pause.imageset/暂停@2x.png new file mode 100644 index 00000000..6e19bb46 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/pause.imageset/暂停@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/pause.imageset/暂停@3x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/pause.imageset/暂停@3x.png new file mode 100644 index 00000000..70d84e29 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/pause.imageset/暂停@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/play.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/HistoryTrack/play.imageset/Contents.json new file mode 100644 index 00000000..6ebcd373 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/HistoryTrack/play.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "Group 2174@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "Group 2174@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/play.imageset/Group 2174@2x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/play.imageset/Group 2174@2x.png new file mode 100644 index 00000000..2c8b1092 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/play.imageset/Group 2174@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/play.imageset/Group 2174@3x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/play.imageset/Group 2174@3x.png new file mode 100644 index 00000000..90276c50 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/play.imageset/Group 2174@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/slider_thumb.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/HistoryTrack/slider_thumb.imageset/Contents.json new file mode 100644 index 00000000..b047110e --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/HistoryTrack/slider_thumb.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "进度条-圆@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "进度条-圆@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/slider_thumb.imageset/进度条-圆@2x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/slider_thumb.imageset/进度条-圆@2x.png new file mode 100644 index 00000000..698b8a24 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/slider_thumb.imageset/进度条-圆@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/slider_thumb.imageset/进度条-圆@3x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/slider_thumb.imageset/进度条-圆@3x.png new file mode 100644 index 00000000..42a94d2f Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/slider_thumb.imageset/进度条-圆@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/start.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/HistoryTrack/start.imageset/Contents.json new file mode 100644 index 00000000..045412f9 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/HistoryTrack/start.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "组 48354@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "组 48354@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/start.imageset/组 48354@2x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/start.imageset/组 48354@2x.png new file mode 100644 index 00000000..ca777359 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/start.imageset/组 48354@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/start.imageset/组 48354@3x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/start.imageset/组 48354@3x.png new file mode 100644 index 00000000..e9f52902 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/start.imageset/组 48354@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/couple_heart.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/couple_heart.imageset/Contents.json new file mode 100644 index 00000000..5c3ca05f --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/couple_heart.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "image@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "image@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/couple_heart.imageset/image@2x.png b/QuickLocation/Assets.xcassets/Home/couple_heart.imageset/image@2x.png new file mode 100644 index 00000000..a3d37d56 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/couple_heart.imageset/image@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/couple_heart.imageset/image@3x.png b/QuickLocation/Assets.xcassets/Home/couple_heart.imageset/image@3x.png new file mode 100644 index 00000000..292d7db3 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/couple_heart.imageset/image@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/couple_member_bg.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/couple_member_bg.imageset/Contents.json new file mode 100644 index 00000000..75d99efd --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/couple_member_bg.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "Rectangle 919@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "Rectangle 919@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/couple_member_bg.imageset/Rectangle 919@2x.png b/QuickLocation/Assets.xcassets/Home/couple_member_bg.imageset/Rectangle 919@2x.png new file mode 100644 index 00000000..53d42e8a Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/couple_member_bg.imageset/Rectangle 919@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/couple_member_bg.imageset/Rectangle 919@3x.png b/QuickLocation/Assets.xcassets/Home/couple_member_bg.imageset/Rectangle 919@3x.png new file mode 100644 index 00000000..5c1e9005 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/couple_member_bg.imageset/Rectangle 919@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Launch/logo.imageset/Contents.json b/QuickLocation/Assets.xcassets/Launch/logo.imageset/Contents.json index 2b0beb8b..eb495db4 100644 --- a/QuickLocation/Assets.xcassets/Launch/logo.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/Launch/logo.imageset/Contents.json @@ -5,12 +5,12 @@ "scale" : "1x" }, { - "filename" : "Group_1483@2x.png", + "filename" : "组 48328@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "Group_1483@3x.png", + "filename" : "组 48328@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/QuickLocation/Assets.xcassets/Launch/logo.imageset/Group_1483@2x.png b/QuickLocation/Assets.xcassets/Launch/logo.imageset/Group_1483@2x.png deleted file mode 100644 index 48fc2c28..00000000 Binary files a/QuickLocation/Assets.xcassets/Launch/logo.imageset/Group_1483@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/Launch/logo.imageset/Group_1483@3x.png b/QuickLocation/Assets.xcassets/Launch/logo.imageset/Group_1483@3x.png deleted file mode 100644 index 0445aca2..00000000 Binary files a/QuickLocation/Assets.xcassets/Launch/logo.imageset/Group_1483@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/Launch/logo.imageset/组 48328@2x.png b/QuickLocation/Assets.xcassets/Launch/logo.imageset/组 48328@2x.png new file mode 100644 index 00000000..cb0374fb Binary files /dev/null and b/QuickLocation/Assets.xcassets/Launch/logo.imageset/组 48328@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Launch/logo.imageset/组 48328@3x.png b/QuickLocation/Assets.xcassets/Launch/logo.imageset/组 48328@3x.png new file mode 100644 index 00000000..a0d1fc1a Binary files /dev/null and b/QuickLocation/Assets.xcassets/Launch/logo.imageset/组 48328@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Launch/slogan.imageset/Contents.json b/QuickLocation/Assets.xcassets/Launch/slogan.imageset/Contents.json index c19b969f..8d272046 100644 --- a/QuickLocation/Assets.xcassets/Launch/slogan.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/Launch/slogan.imageset/Contents.json @@ -5,12 +5,12 @@ "scale" : "1x" }, { - "filename" : "Group_1482@2x.png", + "filename" : "蒙版组 13953@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "Group_1482@3x.png", + "filename" : "蒙版组 13953@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/QuickLocation/Assets.xcassets/Launch/slogan.imageset/Group_1482@2x.png b/QuickLocation/Assets.xcassets/Launch/slogan.imageset/Group_1482@2x.png deleted file mode 100644 index ef2884bb..00000000 Binary files a/QuickLocation/Assets.xcassets/Launch/slogan.imageset/Group_1482@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/Launch/slogan.imageset/Group_1482@3x.png b/QuickLocation/Assets.xcassets/Launch/slogan.imageset/Group_1482@3x.png deleted file mode 100644 index 2074d10d..00000000 Binary files a/QuickLocation/Assets.xcassets/Launch/slogan.imageset/Group_1482@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/Launch/slogan.imageset/蒙版组 13953@2x.png b/QuickLocation/Assets.xcassets/Launch/slogan.imageset/蒙版组 13953@2x.png new file mode 100644 index 00000000..238bb729 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Launch/slogan.imageset/蒙版组 13953@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Launch/slogan.imageset/蒙版组 13953@3x.png b/QuickLocation/Assets.xcassets/Launch/slogan.imageset/蒙版组 13953@3x.png new file mode 100644 index 00000000..c57b9ce4 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Launch/slogan.imageset/蒙版组 13953@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/app_list_lock.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/app_list_lock.imageset/Contents.json new file mode 100644 index 00000000..071cd1ae --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/app_list_lock.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "app_list_lock@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "app_list_lock@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/app_list_lock.imageset/app_list_lock@2x.png b/QuickLocation/Assets.xcassets/LockDistract/app_list_lock.imageset/app_list_lock@2x.png new file mode 100644 index 00000000..22b775fa Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/app_list_lock.imageset/app_list_lock@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/app_list_lock.imageset/app_list_lock@3x.png b/QuickLocation/Assets.xcassets/LockDistract/app_list_lock.imageset/app_list_lock@3x.png new file mode 100644 index 00000000..fa7f30c9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/app_list_lock.imageset/app_list_lock@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/app_locked_overlay.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/app_locked_overlay.imageset/Contents.json new file mode 100644 index 00000000..6538bdae --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/app_locked_overlay.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "app_locked_overlay@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "app_locked_overlay@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/app_locked_overlay.imageset/app_locked_overlay@2x.png b/QuickLocation/Assets.xcassets/LockDistract/app_locked_overlay.imageset/app_locked_overlay@2x.png new file mode 100644 index 00000000..87015b30 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/app_locked_overlay.imageset/app_locked_overlay@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/app_locked_overlay.imageset/app_locked_overlay@3x.png b/QuickLocation/Assets.xcassets/LockDistract/app_locked_overlay.imageset/app_locked_overlay@3x.png new file mode 100644 index 00000000..1a57f334 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/app_locked_overlay.imageset/app_locked_overlay@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/locked_self.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/locked_self.imageset/Contents.json new file mode 100644 index 00000000..6b39eb1a --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/locked_self.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "locked_self@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "locked_self@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/locked_self.imageset/locked_self@2x.png b/QuickLocation/Assets.xcassets/LockDistract/locked_self.imageset/locked_self@2x.png new file mode 100644 index 00000000..7cae8392 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/locked_self.imageset/locked_self@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/locked_self.imageset/locked_self@3x.png b/QuickLocation/Assets.xcassets/LockDistract/locked_self.imageset/locked_self@3x.png new file mode 100644 index 00000000..77d4d2b5 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/locked_self.imageset/locked_self@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Login/bg.imageset/Mask_group@2x.png b/QuickLocation/Assets.xcassets/Login/bg.imageset/Mask_group@2x.png index a3edcee0..00035ddd 100644 Binary files a/QuickLocation/Assets.xcassets/Login/bg.imageset/Mask_group@2x.png and b/QuickLocation/Assets.xcassets/Login/bg.imageset/Mask_group@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Login/bg.imageset/Mask_group@3x.png b/QuickLocation/Assets.xcassets/Login/bg.imageset/Mask_group@3x.png index e7773ec1..237895cf 100644 Binary files a/QuickLocation/Assets.xcassets/Login/bg.imageset/Mask_group@3x.png and b/QuickLocation/Assets.xcassets/Login/bg.imageset/Mask_group@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/avatar_album_badge.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/avatar_album_badge.imageset/Contents.json new file mode 100644 index 00000000..99b4193a --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/avatar_album_badge.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "avatar_album_badge@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "avatar_album_badge@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Mine/avatar_album_badge.imageset/avatar_album_badge@2x.png b/QuickLocation/Assets.xcassets/Mine/avatar_album_badge.imageset/avatar_album_badge@2x.png new file mode 100644 index 00000000..a7c811c9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/avatar_album_badge.imageset/avatar_album_badge@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/avatar_album_badge.imageset/avatar_album_badge@3x.png b/QuickLocation/Assets.xcassets/Mine/avatar_album_badge.imageset/avatar_album_badge@3x.png new file mode 100644 index 00000000..e9316efa Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/avatar_album_badge.imageset/avatar_album_badge@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@2x.png b/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@2x.png index c64e235e..7ae5cad9 100644 Binary files a/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@2x.png and b/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@3x.png b/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@3x.png index 23223358..49a16d21 100644 Binary files a/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@3x.png and b/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/image_placeholder.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/image_placeholder.imageset/Contents.json new file mode 100644 index 00000000..2f9eb897 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/image_placeholder.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "image_placeholder@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "image_placeholder@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/image_placeholder.imageset/image_placeholder@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/image_placeholder.imageset/image_placeholder@2x.png new file mode 100644 index 00000000..0ad3630d Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/image_placeholder.imageset/image_placeholder@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/image_placeholder.imageset/image_placeholder@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/image_placeholder.imageset/image_placeholder@3x.png new file mode 100644 index 00000000..b709f628 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/image_placeholder.imageset/image_placeholder@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/1.imageset/1@2x.png b/QuickLocation/Assets.xcassets/UserIcon/1.imageset/1@2x.png new file mode 100644 index 00000000..8c3d7054 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/1.imageset/1@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/1.imageset/1@3x.png b/QuickLocation/Assets.xcassets/UserIcon/1.imageset/1@3x.png new file mode 100644 index 00000000..d725e2f4 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/1.imageset/1@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/1.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/1.imageset/Contents.json index 3f565bac..b6df79cc 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/1.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/1.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Frame_16@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "1@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Frame_16@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "1@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/1.imageset/Frame_16@2x.png b/QuickLocation/Assets.xcassets/UserIcon/1.imageset/Frame_16@2x.png deleted file mode 100644 index 9d6894d8..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/1.imageset/Frame_16@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/1.imageset/Frame_16@3x.png b/QuickLocation/Assets.xcassets/UserIcon/1.imageset/Frame_16@3x.png deleted file mode 100644 index 1afbfbd8..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/1.imageset/Frame_16@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/10.imageset/10@2x.png b/QuickLocation/Assets.xcassets/UserIcon/10.imageset/10@2x.png new file mode 100644 index 00000000..f7a0f7fc Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/10.imageset/10@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/10.imageset/10@3x.png b/QuickLocation/Assets.xcassets/UserIcon/10.imageset/10@3x.png new file mode 100644 index 00000000..6636766a Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/10.imageset/10@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/10.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/10.imageset/Contents.json index 779a2ba7..e0049945 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/10.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/10.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Frame_20@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "10@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Frame_20@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "10@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/10.imageset/Frame_20@2x.png b/QuickLocation/Assets.xcassets/UserIcon/10.imageset/Frame_20@2x.png deleted file mode 100644 index e69d1d8a..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/10.imageset/Frame_20@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/10.imageset/Frame_20@3x.png b/QuickLocation/Assets.xcassets/UserIcon/10.imageset/Frame_20@3x.png deleted file mode 100644 index b4a38491..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/10.imageset/Frame_20@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/11.imageset/11@2x.png b/QuickLocation/Assets.xcassets/UserIcon/11.imageset/11@2x.png new file mode 100644 index 00000000..ea0b7b2e Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/11.imageset/11@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/11.imageset/11@3x.png b/QuickLocation/Assets.xcassets/UserIcon/11.imageset/11@3x.png new file mode 100644 index 00000000..f4b414a4 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/11.imageset/11@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/11.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/11.imageset/Contents.json index 6fc28917..4dd97566 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/11.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/11.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Frame_21@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "11@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Frame_21@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "11@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/11.imageset/Frame_21@2x.png b/QuickLocation/Assets.xcassets/UserIcon/11.imageset/Frame_21@2x.png deleted file mode 100644 index b1306e64..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/11.imageset/Frame_21@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/11.imageset/Frame_21@3x.png b/QuickLocation/Assets.xcassets/UserIcon/11.imageset/Frame_21@3x.png deleted file mode 100644 index e96601c4..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/11.imageset/Frame_21@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/12.imageset/12@2x.png b/QuickLocation/Assets.xcassets/UserIcon/12.imageset/12@2x.png new file mode 100644 index 00000000..d0a07b02 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/12.imageset/12@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/12.imageset/12@3x.png b/QuickLocation/Assets.xcassets/UserIcon/12.imageset/12@3x.png new file mode 100644 index 00000000..0e66d63b Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/12.imageset/12@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/12.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/12.imageset/Contents.json index 292e6922..843c4d18 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/12.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/12.imageset/Contents.json @@ -5,12 +5,12 @@ "scale" : "1x" }, { - "filename" : "Frame_22@2x.png", + "filename" : "12@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "Frame_22@3x.png", + "filename" : "12@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/QuickLocation/Assets.xcassets/UserIcon/12.imageset/Frame_22@2x.png b/QuickLocation/Assets.xcassets/UserIcon/12.imageset/Frame_22@2x.png deleted file mode 100644 index 7bb8d531..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/12.imageset/Frame_22@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/12.imageset/Frame_22@3x.png b/QuickLocation/Assets.xcassets/UserIcon/12.imageset/Frame_22@3x.png deleted file mode 100644 index a4b00bc1..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/12.imageset/Frame_22@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/13.imageset/13@2x.png b/QuickLocation/Assets.xcassets/UserIcon/13.imageset/13@2x.png new file mode 100644 index 00000000..ec8998d4 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/13.imageset/13@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/13.imageset/13@3x.png b/QuickLocation/Assets.xcassets/UserIcon/13.imageset/13@3x.png new file mode 100644 index 00000000..7e395df5 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/13.imageset/13@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/13.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/13.imageset/Contents.json index de2fc525..e143065b 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/13.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/13.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Frame_23@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "13@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Frame_23@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "13@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/13.imageset/Frame_23@2x.png b/QuickLocation/Assets.xcassets/UserIcon/13.imageset/Frame_23@2x.png deleted file mode 100644 index c9ee4173..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/13.imageset/Frame_23@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/13.imageset/Frame_23@3x.png b/QuickLocation/Assets.xcassets/UserIcon/13.imageset/Frame_23@3x.png deleted file mode 100644 index 26ee22fd..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/13.imageset/Frame_23@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/14.imageset/14@2x.png b/QuickLocation/Assets.xcassets/UserIcon/14.imageset/14@2x.png new file mode 100644 index 00000000..538e896a Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/14.imageset/14@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/14.imageset/14@3x.png b/QuickLocation/Assets.xcassets/UserIcon/14.imageset/14@3x.png new file mode 100644 index 00000000..444f2b12 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/14.imageset/14@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/14.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/14.imageset/Contents.json index 87104237..4cbef761 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/14.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/14.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Frame_24@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "14@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Frame_24@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "14@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/14.imageset/Frame_24@2x.png b/QuickLocation/Assets.xcassets/UserIcon/14.imageset/Frame_24@2x.png deleted file mode 100644 index 3077a6a2..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/14.imageset/Frame_24@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/14.imageset/Frame_24@3x.png b/QuickLocation/Assets.xcassets/UserIcon/14.imageset/Frame_24@3x.png deleted file mode 100644 index ba9e058b..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/14.imageset/Frame_24@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/15.imageset/15@2x.png b/QuickLocation/Assets.xcassets/UserIcon/15.imageset/15@2x.png new file mode 100644 index 00000000..d8820500 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/15.imageset/15@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/15.imageset/15@3x.png b/QuickLocation/Assets.xcassets/UserIcon/15.imageset/15@3x.png new file mode 100644 index 00000000..e2ba316d Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/15.imageset/15@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/15.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/15.imageset/Contents.json index 4bf7edf1..0f0e27f0 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/15.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/15.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Frame_25@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "15@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Frame_25@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "15@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/15.imageset/Frame_25@2x.png b/QuickLocation/Assets.xcassets/UserIcon/15.imageset/Frame_25@2x.png deleted file mode 100644 index efbde5a7..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/15.imageset/Frame_25@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/15.imageset/Frame_25@3x.png b/QuickLocation/Assets.xcassets/UserIcon/15.imageset/Frame_25@3x.png deleted file mode 100644 index 7a8359c8..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/15.imageset/Frame_25@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/16.imageset/12@2x.png b/QuickLocation/Assets.xcassets/UserIcon/16.imageset/12@2x.png new file mode 100644 index 00000000..442a4108 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/16.imageset/12@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/16.imageset/12@3x.png b/QuickLocation/Assets.xcassets/UserIcon/16.imageset/12@3x.png new file mode 100644 index 00000000..4a350dea Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/16.imageset/12@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/16.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/16.imageset/Contents.json new file mode 100644 index 00000000..62ce099a --- /dev/null +++ b/QuickLocation/Assets.xcassets/UserIcon/16.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "12@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "12@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/QuickLocation/Assets.xcassets/UserIcon/2.imageset/2@2x.png b/QuickLocation/Assets.xcassets/UserIcon/2.imageset/2@2x.png new file mode 100644 index 00000000..a0a566db Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/2.imageset/2@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/2.imageset/2@3x.png b/QuickLocation/Assets.xcassets/UserIcon/2.imageset/2@3x.png new file mode 100644 index 00000000..a1a6a7a9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/2.imageset/2@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/2.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/2.imageset/Contents.json index 2c9d8ec2..ac185e16 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/2.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/2.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Frame_15@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "2@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Frame_15@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "2@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/2.imageset/Frame_15@2x.png b/QuickLocation/Assets.xcassets/UserIcon/2.imageset/Frame_15@2x.png deleted file mode 100644 index fc9e8114..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/2.imageset/Frame_15@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/2.imageset/Frame_15@3x.png b/QuickLocation/Assets.xcassets/UserIcon/2.imageset/Frame_15@3x.png deleted file mode 100644 index e61e34a2..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/2.imageset/Frame_15@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/3.imageset/3@2x.png b/QuickLocation/Assets.xcassets/UserIcon/3.imageset/3@2x.png new file mode 100644 index 00000000..c7a640f1 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/3.imageset/3@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/3.imageset/3@3x.png b/QuickLocation/Assets.xcassets/UserIcon/3.imageset/3@3x.png new file mode 100644 index 00000000..29448b4a Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/3.imageset/3@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/3.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/3.imageset/Contents.json index 153d512b..836f34d4 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/3.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/3.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Frame_17@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "3@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Frame_17@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "3@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/3.imageset/Frame_17@2x.png b/QuickLocation/Assets.xcassets/UserIcon/3.imageset/Frame_17@2x.png deleted file mode 100644 index e6591678..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/3.imageset/Frame_17@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/3.imageset/Frame_17@3x.png b/QuickLocation/Assets.xcassets/UserIcon/3.imageset/Frame_17@3x.png deleted file mode 100644 index 2a66416d..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/3.imageset/Frame_17@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/4.imageset/4@2x.png b/QuickLocation/Assets.xcassets/UserIcon/4.imageset/4@2x.png new file mode 100644 index 00000000..93022823 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/4.imageset/4@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/4.imageset/4@3x.png b/QuickLocation/Assets.xcassets/UserIcon/4.imageset/4@3x.png new file mode 100644 index 00000000..c0870f45 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/4.imageset/4@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/4.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/4.imageset/Contents.json index 0c9d7358..e0faa7c3 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/4.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/4.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Group 2371@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "4@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Group 2371@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "4@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/4.imageset/Group 2371@2x.png b/QuickLocation/Assets.xcassets/UserIcon/4.imageset/Group 2371@2x.png deleted file mode 100644 index c9e61d47..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/4.imageset/Group 2371@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/4.imageset/Group 2371@3x.png b/QuickLocation/Assets.xcassets/UserIcon/4.imageset/Group 2371@3x.png deleted file mode 100644 index d0784ef9..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/4.imageset/Group 2371@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/5.imageset/5@2x.png b/QuickLocation/Assets.xcassets/UserIcon/5.imageset/5@2x.png new file mode 100644 index 00000000..f1d01883 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/5.imageset/5@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/5.imageset/5@3x.png b/QuickLocation/Assets.xcassets/UserIcon/5.imageset/5@3x.png new file mode 100644 index 00000000..e0274a35 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/5.imageset/5@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/5.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/5.imageset/Contents.json index 2f693a10..2b80de89 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/5.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/5.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Frame_14@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "5@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Frame_14@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "5@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/5.imageset/Frame_14@2x.png b/QuickLocation/Assets.xcassets/UserIcon/5.imageset/Frame_14@2x.png deleted file mode 100644 index 13999312..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/5.imageset/Frame_14@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/5.imageset/Frame_14@3x.png b/QuickLocation/Assets.xcassets/UserIcon/5.imageset/Frame_14@3x.png deleted file mode 100644 index 8b21c84c..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/5.imageset/Frame_14@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/6.imageset/6@2x.png b/QuickLocation/Assets.xcassets/UserIcon/6.imageset/6@2x.png new file mode 100644 index 00000000..a2e4b9a5 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/6.imageset/6@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/6.imageset/6@3x.png b/QuickLocation/Assets.xcassets/UserIcon/6.imageset/6@3x.png new file mode 100644 index 00000000..6d828fd6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/6.imageset/6@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/6.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/6.imageset/Contents.json index d5f491f8..8b3fdb3a 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/6.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/6.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Frame_12@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "6@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Frame_12@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "6@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/6.imageset/Frame_12@2x.png b/QuickLocation/Assets.xcassets/UserIcon/6.imageset/Frame_12@2x.png deleted file mode 100644 index 729de242..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/6.imageset/Frame_12@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/6.imageset/Frame_12@3x.png b/QuickLocation/Assets.xcassets/UserIcon/6.imageset/Frame_12@3x.png deleted file mode 100644 index d263933d..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/6.imageset/Frame_12@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/7.imageset/7@2x.png b/QuickLocation/Assets.xcassets/UserIcon/7.imageset/7@2x.png new file mode 100644 index 00000000..2ff391e3 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/7.imageset/7@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/7.imageset/7@3x.png b/QuickLocation/Assets.xcassets/UserIcon/7.imageset/7@3x.png new file mode 100644 index 00000000..1694f746 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/7.imageset/7@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/7.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/7.imageset/Contents.json index 68f307c9..e002b260 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/7.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/7.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Frame_13@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "7@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Frame_13@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "7@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/7.imageset/Frame_13@2x.png b/QuickLocation/Assets.xcassets/UserIcon/7.imageset/Frame_13@2x.png deleted file mode 100644 index 43a07985..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/7.imageset/Frame_13@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/7.imageset/Frame_13@3x.png b/QuickLocation/Assets.xcassets/UserIcon/7.imageset/Frame_13@3x.png deleted file mode 100644 index b3adcdf6..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/7.imageset/Frame_13@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/8.imageset/8@2x.png b/QuickLocation/Assets.xcassets/UserIcon/8.imageset/8@2x.png new file mode 100644 index 00000000..916528cf Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/8.imageset/8@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/8.imageset/8@3x.png b/QuickLocation/Assets.xcassets/UserIcon/8.imageset/8@3x.png new file mode 100644 index 00000000..a98eba41 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/8.imageset/8@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/8.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/8.imageset/Contents.json index 75b04223..50baeae6 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/8.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/8.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Frame_18@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "8@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Frame_18@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "8@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/8.imageset/Frame_18@2x.png b/QuickLocation/Assets.xcassets/UserIcon/8.imageset/Frame_18@2x.png deleted file mode 100644 index 8e8b0d45..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/8.imageset/Frame_18@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/8.imageset/Frame_18@3x.png b/QuickLocation/Assets.xcassets/UserIcon/8.imageset/Frame_18@3x.png deleted file mode 100644 index 1786da32..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/8.imageset/Frame_18@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/9.imageset/9@2x.png b/QuickLocation/Assets.xcassets/UserIcon/9.imageset/9@2x.png new file mode 100644 index 00000000..bb191c04 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/9.imageset/9@2x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/9.imageset/9@3x.png b/QuickLocation/Assets.xcassets/UserIcon/9.imageset/9@3x.png new file mode 100644 index 00000000..798a9a72 Binary files /dev/null and b/QuickLocation/Assets.xcassets/UserIcon/9.imageset/9@3x.png differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/9.imageset/Contents.json b/QuickLocation/Assets.xcassets/UserIcon/9.imageset/Contents.json index 0b0ad687..ab6d062b 100644 --- a/QuickLocation/Assets.xcassets/UserIcon/9.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/UserIcon/9.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "scale" : "1x" + "idiom": "universal", + "scale": "1x" }, { - "filename" : "Frame_19@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "9@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "filename" : "Frame_19@3x.png", - "idiom" : "universal", - "scale" : "3x" + "filename": "9@3x.png", + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/QuickLocation/Assets.xcassets/UserIcon/9.imageset/Frame_19@2x.png b/QuickLocation/Assets.xcassets/UserIcon/9.imageset/Frame_19@2x.png deleted file mode 100644 index 497168f2..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/9.imageset/Frame_19@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/UserIcon/9.imageset/Frame_19@3x.png b/QuickLocation/Assets.xcassets/UserIcon/9.imageset/Frame_19@3x.png deleted file mode 100644 index 322419fd..00000000 Binary files a/QuickLocation/Assets.xcassets/UserIcon/9.imageset/Frame_19@3x.png and /dev/null differ diff --git a/QuickLocation/Common/Constant.swift b/QuickLocation/Common/Constant.swift index 205724c2..7d5eda72 100644 --- a/QuickLocation/Common/Constant.swift +++ b/QuickLocation/Common/Constant.swift @@ -45,6 +45,12 @@ extension DefaultsKeys { var screenLastOnAt: DefaultsKey { .init("screenLastOnAt") } /// 手机报告:本 App 今日进入前台次数 var appUsageToday: DefaultsKey { .init("appUsageToday", defaultValue: 0) } + /// 屏幕使用时间权限曾开启的账号 + var appRestrictApprovedUserIds: DefaultsKey<[String]> { .init("appRestrictApprovedUserIds", defaultValue: []) } + /// 等待执行服务端配对全删的账号 + var appRestrictPendingDeleteUserIds: DefaultsKey<[String]> { .init("appRestrictPendingDeleteUserIds", defaultValue: []) } + /// 当前本地配对数据所属账号 + var appRestrictPairingOwnerUserId: DefaultsKey { .init("appRestrictPairingOwnerUserId", defaultValue: "") } } /// 通知常量 @@ -67,6 +73,10 @@ extension Notification.Name { static let invalidatePopupQueue = Notification.Name("invalidatePopupQueue") /// 本机解锁次数变化 static let unlockCountDidChange = Notification.Name("unlockCountDidChange") + /// 本机应用配对数据变化 + static let appRestrictPairingDataDidChange = Notification.Name("appRestrictPairingDataDidChange") + /// MQTT 应用锁定状态变化 + static let lockDistractAppsDidChange = Notification.Name("lockDistractAppsDidChange") } diff --git a/QuickLocation/Info.plist b/QuickLocation/Info.plist index 3b6e95c8..201c2898 100644 --- a/QuickLocation/Info.plist +++ b/QuickLocation/Info.plist @@ -41,6 +41,13 @@ NSExceptionDomains + cdn2.batiao8.com + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + 38.207.176.65 NSExceptionAllowsInsecureHTTPLoads diff --git a/QuickLocation/Main/BaseModel/BaseModelNew.swift b/QuickLocation/Main/BaseModel/BaseModelNew.swift index 6c0cafe9..e11d99d0 100644 --- a/QuickLocation/Main/BaseModel/BaseModelNew.swift +++ b/QuickLocation/Main/BaseModel/BaseModelNew.swift @@ -57,6 +57,9 @@ let kStrTransformInt = TransformOf(fromJSON: transformInt, let kIntTransformStr = TransformOf(fromJSON: transformStr, toJSON: transformInt) +/// 数字或字符串都转成 String,toJSON 时原样保留(头像 URL 不能走 Int) +let kFlexString = TransformOf(fromJSON: transformStr, toJSON: { $0 }) + /// JsonString >> Json let kJsonStrTransformJson = TransformOf(fromJSON: transformJSON, toJSON: transformJsonStr) diff --git a/QuickLocation/Manager/Account/Account.swift b/QuickLocation/Manager/Account/Account.swift index f0ebe0f0..f1b4a763 100644 --- a/QuickLocation/Manager/Account/Account.swift +++ b/QuickLocation/Manager/Account/Account.swift @@ -18,6 +18,7 @@ extension DefaultsKeys { var searchShopHistory: DefaultsKey<[String]> { .init("searchShopHistory", defaultValue: []) } var searchOrderHistory: DefaultsKey<[String]> { .init("searchOrderHistory", defaultValue: []) } var userRelations: DefaultsKey { .init("userRelations") } + var defaultGroupKey: DefaultsKey { .init("defaultGroupKey", defaultValue: "") } } struct Account: Mappable { diff --git a/QuickLocation/Manager/Account/AppContextManager.swift b/QuickLocation/Manager/Account/AppContextManager.swift index 425c4538..e4df4462 100644 --- a/QuickLocation/Manager/Account/AppContextManager.swift +++ b/QuickLocation/Manager/Account/AppContextManager.swift @@ -41,10 +41,13 @@ class AppContextManager: NSObject { /// 用户头像 var avaterIcon: UIImage { guard let account = account else { return UIImage() } - return UIImage(named: "UserIcon/\(account.head_pic)") ?? UIImage() + return HeadPic.image(for: account.displayHeadPic) } var head_pic: String { - account?.head_pic ?? "" + guard let account else { return "" } + let headPic = account.head_pic.trimmingCharacters(in: .whitespacesAndNewlines) + if !headPic.isEmpty { return headPic } + return account.avater?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } /// 用户名 var name: String { @@ -84,6 +87,7 @@ class AppContextManager: NSObject { Defaults[\.userConfig] = jsonData loginAccount = tmpAccount + AppUnlockCoordinator.shared.checkOfflineUnlockForCurrentUser() return true } @@ -114,9 +118,16 @@ class AppContextManager: NSObject { saveAccount(account) } + var defaultGroupKey: String { + get { Defaults[\.defaultGroupKey] } + set { Defaults[\.defaultGroupKey] = newValue } + } + public func deleteAccount() { + AppUnlockCoordinator.shared.accountDidLogout(userId: userId) Defaults[\.loginToken] = nil Defaults[\.userConfig] = nil + Defaults[\.defaultGroupKey] = "" loginAccount = nil } } diff --git a/QuickLocation/Manager/Account/UserConfigModel.swift b/QuickLocation/Manager/Account/UserConfigModel.swift index 7b6439bc..115e28f1 100644 --- a/QuickLocation/Manager/Account/UserConfigModel.swift +++ b/QuickLocation/Manager/Account/UserConfigModel.swift @@ -15,9 +15,14 @@ struct UserConfigModel: Mappable { var avater: String? /// 头像,对应本地图标 var head_pic: String = "1" + /// 展示用头像:优先上传 URL(head_pic 或 avater),否则本地序号 + var displayHeadPic: String { + HeadPic.resolved(head_pic, avater) + } + /// 用户头像 var userIcon: UIImage { - UIImage(named: "UserIcon/\(head_pic)") ?? UIImage() + HeadPic.image(for: displayHeadPic) } /// 性别 var sex: Int = -1 @@ -52,7 +57,7 @@ struct UserConfigModel: Mappable { uid <- map["user_id"] avater <- map["avater"] sex <- map["sex"] - head_pic <- (map["head_pic"], kIntTransformStr) + head_pic <- (map["head_pic"], kFlexString) config <- map["config"] vip <- map["vip"] vip_name <- map["vip_name"] diff --git a/QuickLocation/Manager/Account/UserConfigResponse.swift b/QuickLocation/Manager/Account/UserConfigResponse.swift index 716c002e..c38ae291 100644 --- a/QuickLocation/Manager/Account/UserConfigResponse.swift +++ b/QuickLocation/Manager/Account/UserConfigResponse.swift @@ -100,6 +100,26 @@ struct UserStatusModel: Mappable { } } +struct PhoneUsageTripsResponse: BaseModelProtocol { + var code: String? + var message: String? + var success: Bool = false + var date: String = "" + var userId: String = "" + var trips: [ScheduleRecordModel] = [] + + init?(map: Map) {} + + mutating func mapping(map: Map) { + code <- (map["code"], kIntTransformStr) + message <- map["message"] + success <- map["success"] + date <- map["data.date"] + userId <- (map["data.user_id"], kIntTransformStr) + trips <- map["data.trips"] + } +} + struct PhoneUsageTodayResponse: BaseModelProtocol { var code: String? var message: String? @@ -340,3 +360,296 @@ struct PhoneUsageStayAddressModel: Mappable { street <- (map["street"], kIntTransformStr) } } + +struct PhoneUsageIconResponse: BaseModelProtocol { + var code: String? + var message: String? + var fileId: String = "" + + static var empty: PhoneUsageIconResponse { + Mapper().map(JSON: [:])! + } + + init?(map: Map) {} + + mutating func mapping(map: Map) { + code <- (map["code"], kIntTransformStr) + message <- map["message"] + if message == nil { + message <- map["msg"] + } + fileId <- (map["data.file_id"], kIntTransformStr) + if fileId.isEmpty { + fileId <- (map["data.icon"], kIntTransformStr) + } + if fileId.isEmpty { + fileId <- (map["data.id"], kIntTransformStr) + } + if fileId.isEmpty { + fileId <- (map["data"], kIntTransformStr) + } + } +} + +struct PhoneLockAppsResponse: BaseModelProtocol { + var code: String? + var message: String? + var model: PhoneLockAppsModel? + var fromUser: String = "" + + init?(map: Map) {} + + mutating func mapping(map: Map) { + code <- (map["code"], kIntTransformStr) + message <- map["message"] + if message == nil { + message <- map["msg"] + } + model <- map["data"] + fromUser <- (map["data.from_user"], kIntTransformStr) + if fromUser.isEmpty { + fromUser <- (map["from_user"], kIntTransformStr) + } + } + + var resolvedFromUser: String { + let modelFromUser = model?.resolvedFromUser ?? "" + return modelFromUser.isEmpty ? fromUser.trimmed : modelFromUser + } +} + +struct PhoneLockAppsModel: Mappable { + var apps: [PhoneLockAppItem] = [] + var fromUser: String = "" + + init?(map: Map) {} + + mutating func mapping(map: Map) { + apps <- map["apps"] + fromUser <- (map["from_user"], kIntTransformStr) + } + + var resolvedFromUser: String { + let value = fromUser.trimmed + if !value.isEmpty { return value } + return apps.first { $0.locked && !$0.fromUser.trimmed.isEmpty }?.fromUser.trimmed ?? "" + } +} + +struct PhoneLockAppItem: Mappable { + var os: String = "" + var token: String = "" + var icon: String = "" + var locked: Bool = false + var fromUser: String = "" + + init?(map: Map) {} + + mutating func mapping(map: Map) { + os <- (map["os"], kIntTransformStr) + token <- (map["token"], kIntTransformStr) + icon <- (map["icon"], kIntTransformStr) + fromUser <- (map["from_user"], kIntTransformStr) + locked = Self.parseBool(map.JSON["locked"]) || Self.parseBool(map.JSON["is_locked"]) + } + + private static func parseBool(_ value: Any?) -> Bool { + switch value { + case let flag as Bool: + return flag + case let number as Int: + return number != 0 + case let number as Double: + return number != 0 + case let text as String: + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed == "1" || trimmed == "true" || trimmed == "yes" + default: + return false + } + } +} + +struct PhoneLockedResponse: BaseModelProtocol { + var code: String? + var message: String? + var model: PhoneLockedModel? + + init?(map: Map) {} + + mutating func mapping(map: Map) { + code <- (map["code"], kIntTransformStr) + message <- map["message"] + if message == nil { + message <- map["msg"] + } + model <- map["data"] + } +} + +struct PhoneUnlockAllowResponse: BaseModelProtocol { + var code: String? + var message: String? + var model: PhoneLockedModel? + + init?(map: Map) {} + + mutating func mapping(map: Map) { + code <- (map["code"], kIntTransformStr) + message <- map["message"] + if message == nil { + message <- map["msg"] + } + model <- map["data"] + } +} + +struct PhoneLockedModel: Mappable { + var locks: [PhoneLockRecord] = [] + + init?(map: Map) {} + + mutating func mapping(map: Map) { + locks <- map["locks"] + } +} + +struct PhoneLockRecord: Mappable { + var id: String = "" + var fromUser: String = "" + var groupKey: String = "" + var groupName: String = "" + var userId: String = "" + var tokens: [String] = [] + var os: String = "" + var iconIndex: Int = 0 + var message: String = "" + var lockTime: Int64 = 0 + var appIcon: String = "" + + init() {} + + init?(map: Map) {} + + mutating func mapping(map: Map) { + id <- (map["id"], kIntTransformStr) + fromUser <- (map["from_user"], kIntTransformStr) + groupKey <- (map["group_key"], kIntTransformStr) + groupName <- (map["group_name"], kIntTransformStr) + userId <- (map["user_id"], kIntTransformStr) + os <- (map["os"], kIntTransformStr) + iconIndex <- (map["icon_index"], kStrTransformInt) + message <- (map["message"], kIntTransformStr) + lockTime <- (map["lock_time"], kUserStatusTransformInt64) + appIcon <- (map["app_icon"], kIntTransformStr) + tokens = Self.parseTokens(map.JSON["token"]) + } + + var displayGroupName: String { + let name = groupName.trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? "圈子" : name + } + + var lockStartDate: Date { + guard lockTime > 0 else { return Date() } + let seconds = lockTime >= 100_000_000_000 + ? TimeInterval(lockTime) / 1000 + : TimeInterval(lockTime) + return Date(timeIntervalSince1970: seconds) + } + + var wallpaperImageName: String { + let index = min(max(iconIndex, 0), 6) + 1 + return "LockDistract/lock_icon_\(index)" + } + + static func parseTokens(_ value: Any?) -> [String] { + if let array = value as? [Any] { + return array.compactMap(transformStr).filter { !$0.isEmpty } + } + if let text = transformStr(value), !text.isEmpty { + return [text] + } + return [] + } +} + +enum PhoneLockSession { + static var currentLocks: [PhoneLockRecord] = [] +} + +struct PhoneUnlockRequestsResponse: BaseModelProtocol { + var code: String? + var message: String? + var list: [PhoneUnlockRequestItem] = [] + + init?(map: Map) {} + + mutating func mapping(map: Map) { + code <- (map["code"], kIntTransformStr) + message <- map["message"] + if message == nil { + message <- map["msg"] + } + list <- map["data.requests"] + if list.isEmpty { + list <- map["data.list"] + } + if list.isEmpty { + list <- map["data.locks"] + } + if list.isEmpty { + list <- map["data"] + } + } +} + +struct PhoneUnlockRequestItem: Mappable { + var fromUser: String = "" + var userId: String = "" + var toUser: String = "" + var groupKey: String = "" + var os: String = "" + var nickName: String = "" + var headPic: String = "" + var tokens: [String] = [] + var lockTime: Int64 = 0 + var requestUnlock: Int = 0 + + init?(map: Map) {} + + mutating func mapping(map: Map) { + fromUser <- (map["from_user"], kIntTransformStr) + userId <- (map["user_id"], kIntTransformStr) + toUser <- (map["to_user"], kIntTransformStr) + groupKey <- (map["group_key"], kIntTransformStr) + os <- (map["os"], kIntTransformStr) + nickName <- (map["nick_name"], kIntTransformStr) + if nickName.isEmpty { + nickName <- (map["name"], kIntTransformStr) + } + headPic <- (map["head_pic"], kFlexString) + if headPic.isEmpty { + headPic <- (map["avater"], kFlexString) + } + lockTime <- (map["lock_time"], kUserStatusTransformInt64) + requestUnlock <- (map["request_unlock"], kStrTransformInt) + tokens = PhoneLockRecord.parseTokens(map.JSON["token"]) + } + + var targetUserId: String { + let to = toUser.trimmingCharacters(in: .whitespacesAndNewlines) + if !to.isEmpty { return to } + let from = fromUser.trimmingCharacters(in: .whitespacesAndNewlines) + if !from.isEmpty { return from } + return userId.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var lockStartDate: Date { + guard lockTime > 0 else { return Date() } + let seconds = lockTime >= 100_000_000_000 + ? TimeInterval(lockTime) / 1000 + : TimeInterval(lockTime) + return Date(timeIntervalSince1970: seconds) + } +} diff --git a/QuickLocation/Manager/App/ApiManager.swift b/QuickLocation/Manager/App/ApiManager.swift index 891f2a22..8cc28cc0 100644 --- a/QuickLocation/Manager/App/ApiManager.swift +++ b/QuickLocation/Manager/App/ApiManager.swift @@ -108,6 +108,8 @@ extension ApiManager { handleTokenExpired(msg: message) case GatewayStatusCode.failure.rawValue, GatewayStatusCode.noAuthority.rawValue: // 退出当前界面 handlePopView(message, handle) + case GatewayStatusCode.needVip.rawValue: + handleNeedVip(message ?? "") default: // 接口没返回code的时候取success字段 if success != nil { @@ -176,6 +178,15 @@ extension ApiManager { } } } + + // MARK: - 需开通VIP + private func handleNeedVip(_ msg: String) { + MainAsync { + ConfirmPopVC.showAlert(title: "温馨提示", message: msg, confirmText: "去开通", confirmBlock: { + AppRouter.push(Route.vipRecharge) + }) + } + } } extension ApiManager { diff --git a/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift b/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift index f4e5acec..859dc61d 100644 --- a/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift +++ b/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift @@ -7,17 +7,27 @@ import DeviceActivity import FamilyControls import Foundation import ManagedSettings +import RxSwift +import SwiftyUserDefaults import UIKit @available(iOS 16.0, *) -final class AppRestrictManager { +final class AppRestrictManager: NSObject { static let shared = AppRestrictManager() private let center = AuthorizationCenter.shared private let activityCenter = DeviceActivityCenter() private let activityName = DeviceActivityName(AppRestrictShared.activityName) + private let permissionConfirmationDelay: TimeInterval = 1 + private var isPermissionMonitoringStarted = false + private var permissionCheckGeneration = 0 + private var permissionCheckWorkItem: DispatchWorkItem? + private var isDeletingAllApps = false + private var deleteAllDisposable: Disposable? - private init() {} + private override init() { + super.init() + } var authorizationStatus: AuthorizationStatus { center.authorizationStatus @@ -42,6 +52,7 @@ final class AppRestrictManager { // Drop enabled tokens that are no longer in selection let apps = normalized.applicationTokens AppRestrictSharedStore.enabledTokens = AppRestrictSharedStore.enabledTokens.intersection(apps) + recordPairingOwnerIfNeeded() refreshMonitoringAndShield() } } @@ -59,9 +70,33 @@ final class AppRestrictManager { } func requestAuthorization() async throws { + defer { synchronizePermissionState() } try await center.requestAuthorization(for: .individual) } + func startPermissionMonitoring() { + guard !isPermissionMonitoringStarted else { return } + isPermissionMonitoringStarted = true + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(currentAccountDidChange), + name: .RefreshUserConfigNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(protectedDataDidBecomeAvailable), + name: UIApplication.protectedDataDidBecomeAvailableNotification, + object: nil + ) + } + func mergeSelection(_ incoming: FamilyActivitySelection) { var current = selection current.applicationTokens.formUnion(incoming.applicationTokens) @@ -99,6 +134,7 @@ final class AppRestrictManager { displayName: displayName, iconURL: iconURL ) + recordPairingOwnerIfNeeded() } func catalogItem(for token: ApplicationToken) -> AppCatalogItem? { @@ -119,6 +155,10 @@ final class AppRestrictManager { var current = selection current.applicationTokens.remove(token) selection = current + if !AppRestrictSharedStore.hasPairingData { + Defaults[\.appRestrictPairingOwnerUserId] = "" + } + notifyPairingDataChanged() } var shieldConfig: AppRestrictShieldConfig { @@ -131,6 +171,54 @@ final class AppRestrictManager { AppRestrictSharedStore.saveCustomImage(image) } + func applyRemoteLock(tokens: [String], iconIndex: Int, message: String, groupName: String) { + applyRemoteLockAppearance(iconIndex: iconIndex, message: message, groupName: groupName) + guard isAuthorized else { + print("[AppRestrict] remote lock ignored: Screen Time authorization is not approved") + return + } + let incomingTokens = decodedRemoteTokens(tokens) + guard !incomingTokens.isEmpty else { + print("[AppRestrict] remote lock ignored: no decodable application tokens") + return + } + enabledTokens = enabledTokens.union(incomingTokens) + recordPairingOwnerIfNeeded() + print("[AppRestrict] remote lock applied: \(incomingTokens.count) application(s)") + } + + func applyRemoteUnlock(tokens: [String]) { + if tokens.isEmpty { + enabledTokens = [] + return + } + let incomingTokens = decodedRemoteTokens(tokens) + guard !incomingTokens.isEmpty else { + print("[AppRestrict] remote unlock ignored: no decodable application tokens") + return + } + enabledTokens = enabledTokens.subtracting(incomingTokens) + } + + private func decodedRemoteTokens(_ values: [String]) -> Set { + Set(values.compactMap(AppRestrictTokenCodec.decodeBase64)) + } + + func applyRemoteLockAppearance(iconIndex: Int, message: String, groupName: String) { + let name = groupName.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = name.isEmpty ? "圈子" : name + var config = shieldConfig + config.title = "APP已被 \(displayName) 锁定" + config.subtitle = message + config.primaryButtonLabel = "打开 极速定位 解锁" + config.imageSource = .album + let index = min(max(iconIndex, 0), 6) + 1 + if let image = UIImage(named: "LockDistract/lock_icon_\(index)") { + _ = saveCustomShieldImage(image) + } + shieldConfig = config + } + func refreshMonitoringAndShield() { let tokens = enabledTokens AppRestrictSharedStore.applyShield(for: tokens) @@ -149,4 +237,166 @@ final class AppRestrictManager { print("[AppRestrict] startMonitoring failed: \(error)") } } + + @objc private func applicationDidBecomeActive() { + synchronizePermissionState() + } + + @objc private func currentAccountDidChange() { + synchronizePermissionState() + } + + @objc private func protectedDataDidBecomeAvailable() { + synchronizePermissionState() + } + + private func synchronizePermissionState() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.synchronizePermissionState() + } + return + } + + guard UIApplication.shared.applicationState == .active, + UIApplication.shared.isProtectedDataAvailable else { return } + + let currentUserId = AppContextManager.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines) + var approvedUserIds = Set(Defaults[\.appRestrictApprovedUserIds]) + + if center.authorizationStatus == .approved { + cancelPendingPermissionCheck() + if !currentUserId.isEmpty { + approvedUserIds.insert(currentUserId) + Defaults[\.appRestrictApprovedUserIds] = Array(approvedUserIds) + if AppRestrictSharedStore.hasPairingData, + Defaults[\.appRestrictPairingOwnerUserId].isEmpty { + Defaults[\.appRestrictPairingOwnerUserId] = currentUserId + } + } + retryPendingDeleteIfNeeded(for: currentUserId) + return + } + + retryPendingDeleteIfNeeded(for: currentUserId) + schedulePermissionConfirmation() + } + + private func schedulePermissionConfirmation() { + permissionCheckWorkItem?.cancel() + permissionCheckGeneration += 1 + let generation = permissionCheckGeneration + let workItem = DispatchWorkItem { [weak self] in + guard let self, + self.permissionCheckGeneration == generation else { return } + self.permissionCheckWorkItem = nil + self.confirmNonApprovedPermissionState() + } + permissionCheckWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + permissionConfirmationDelay, execute: workItem) + } + + private func cancelPendingPermissionCheck() { + permissionCheckWorkItem?.cancel() + permissionCheckWorkItem = nil + permissionCheckGeneration += 1 + } + + private func confirmNonApprovedPermissionState() { + guard UIApplication.shared.applicationState == .active, + UIApplication.shared.isProtectedDataAvailable else { return } + + guard center.authorizationStatus != .approved else { + synchronizePermissionState() + return + } + + let currentUserId = AppContextManager.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines) + var approvedUserIds = Set(Defaults[\.appRestrictApprovedUserIds]) + let pairingOwnerUserId = Defaults[\.appRestrictPairingOwnerUserId] + .trimmingCharacters(in: .whitespacesAndNewlines) + let hasPairingData = AppRestrictSharedStore.hasPairingData + let cleanupUserId: String + if hasPairingData, !pairingOwnerUserId.isEmpty { + cleanupUserId = pairingOwnerUserId + } else if !currentUserId.isEmpty, + hasPairingData || approvedUserIds.contains(currentUserId) { + cleanupUserId = currentUserId + } else if hasPairingData, approvedUserIds.count == 1 { + cleanupUserId = approvedUserIds.first ?? "" + } else if !pairingOwnerUserId.isEmpty, + approvedUserIds.contains(pairingOwnerUserId) { + cleanupUserId = pairingOwnerUserId + } else { + cleanupUserId = "" + } + + let permissionWasApproved = !cleanupUserId.isEmpty && approvedUserIds.contains(cleanupUserId) + if hasPairingData || permissionWasApproved { + print("[AppRestrict] permission confirmed unavailable; clearing local pairing data") + if !cleanupUserId.isEmpty { + approvedUserIds.remove(cleanupUserId) + Defaults[\.appRestrictApprovedUserIds] = Array(approvedUserIds) + addPendingDeleteUserId(cleanupUserId) + } + clearLocalPairingData() + } + + retryPendingDeleteIfNeeded(for: currentUserId) + } + + private func recordPairingOwnerIfNeeded() { + guard AppRestrictSharedStore.hasPairingData, + Defaults[\.appRestrictPairingOwnerUserId].isEmpty else { return } + let userId = AppContextManager.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !userId.isEmpty else { return } + Defaults[\.appRestrictPairingOwnerUserId] = userId + } + + private func clearLocalPairingData() { + activityCenter.stopMonitoring([activityName]) + AppRestrictSharedStore.clearPairingData() + PhoneLockSession.currentLocks = [] + Defaults[\.appRestrictPairingOwnerUserId] = "" + notifyPairingDataChanged() + } + + private func notifyPairingDataChanged() { + NotificationCenter.default.post(name: .appRestrictPairingDataDidChange, object: nil) + } + + private func addPendingDeleteUserId(_ userId: String) { + var pendingUserIds = Set(Defaults[\.appRestrictPendingDeleteUserIds]) + pendingUserIds.insert(userId) + Defaults[\.appRestrictPendingDeleteUserIds] = Array(pendingUserIds) + } + + private func retryPendingDeleteIfNeeded(for userId: String) { + guard !userId.isEmpty, + !isDeletingAllApps, + Defaults[\.appRestrictPendingDeleteUserIds].contains(userId) else { return } + + isDeletingAllApps = true + deleteAllDisposable = UserService.phoneLockAppsDelete(tokens: []) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] response in + guard let self else { return } + if response.code == "0" { + var pendingUserIds = Set(Defaults[\.appRestrictPendingDeleteUserIds]) + pendingUserIds.remove(userId) + Defaults[\.appRestrictPendingDeleteUserIds] = Array(pendingUserIds) + } else { + print("[AppRestrict] delete all paired apps failed: \(response.message ?? "unknown error")") + } + self.finishDeleteAllRequest() + }, onError: { [weak self] error in + print("[AppRestrict] delete all paired apps failed: \(error.gatewayMessage ?? error.localizedDescription)") + self?.finishDeleteAllRequest() + }) + } + + private func finishDeleteAllRequest() { + isDeletingAllApps = false + deleteAllDisposable = nil + } } diff --git a/QuickLocation/Manager/MQTT/MQTTService.swift b/QuickLocation/Manager/MQTT/MQTTService.swift index 63c5c1bf..0441a7dd 100644 --- a/QuickLocation/Manager/MQTT/MQTTService.swift +++ b/QuickLocation/Manager/MQTT/MQTTService.swift @@ -12,6 +12,7 @@ import CoreLocation import Network import CoreTelephony import AVFoundation +import RxSwift // MARK: - MQTT 模型 @@ -30,6 +31,8 @@ enum MqttType: String, Codable { case emote = "emote" // 接收表情 case phone = "phone" // 手机信息上报 case phoneUsage = "phoneUsage" // 当前 App 使用次数上报 + case lockApp = "lockApp" // 远程锁 App + case appUnlock = "appUnlock" // 远程解锁 App } /// 单点位置 @@ -71,6 +74,527 @@ struct MqttIncomingData: Decodable { let index: Int? let group_key: String? let user_id: String? + let lock_app: MqttLockAppBody? + let app_unlock: MqttLockAppBody? + let sender: String? +} + +struct MqttLockAppBody: Decodable { + let os: String? + let group_key: String? + let group_name: String? + let user_id: String? + let icon_index: Int? + let message: String? + let lock_time: Int64? + let app_icon: String? + let token: MqttFlexibleStringArray? +} + +enum MqttFlexibleStringArray: Decodable { + case one(String) + case many([String]) + + var values: [String] { + switch self { + case .one(let value): + return value.isEmpty ? [] : [value] + case .many(let values): + return values.filter { !$0.isEmpty } + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let values = try? container.decode([String].self) { + self = .many(values) + return + } + if let value = try? container.decode(String.self) { + self = .one(value) + return + } + if let value = try? container.decode(Int.self) { + self = .one(String(value)) + return + } + self = .many([]) + } +} + +extension PhoneLockRecord { + init(mqtt body: MqttLockAppBody) { + self.init() + groupKey = body.group_key ?? "" + groupName = body.group_name ?? "" + userId = body.user_id ?? "" + tokens = body.token?.values ?? [] + os = body.os ?? "" + iconIndex = body.icon_index ?? 0 + message = body.message ?? "" + lockTime = body.lock_time ?? 0 + appIcon = body.app_icon ?? "" + } +} + +private struct MqttAppUnlockReceiptBody: Codable { + let os: String + let group_key: String + let group_name: String + let user_id: String + let token: [String] +} + +private struct MqttAppUnlockReceiptData: Codable { + let app_unlock: MqttAppUnlockReceiptBody +} + +private struct MqttAppUnlockReceiptPayload: Codable { + let type: String + let data: MqttAppUnlockReceiptData + let extra: String +} + +private struct PendingAppUnlockReceipt: Codable, Equatable { + let identifier: String + let userId: String + let payload: String + + init(userId: String, payload: String) { + identifier = UUID().uuidString + self.userId = userId + self.payload = payload + } + + private enum CodingKeys: String, CodingKey { + case identifier + case userId + case payload + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + identifier = try container.decodeIfPresent(String.self, forKey: .identifier) ?? UUID().uuidString + userId = try container.decode(String.self, forKey: .userId) + payload = try container.decode(String.self, forKey: .payload) + } +} + +private struct ExpectedAppUnlockEcho { + var count: Int + var expiresAt: Date +} + +final class AppUnlockCoordinator { + static let shared = AppUnlockCoordinator() + + private let pendingReceiptsKey = "AppUnlockPendingReceipts" + private var pendingReceipts: [PendingAppUnlockReceipt] + private var inFlightReceipts: [UInt16: PendingAppUnlockReceipt] = [:] + private var expectedOutgoingEchoes: [String: ExpectedAppUnlockEcho] = [:] + private var allowRequestUserId = "" + private var completedAllowUserIds: Set = [] + private var observedUserId = "" + private var lockStateRevision: UInt64 = 0 + private var locallyUnlockedTokensByUserId: [String: Set] = [:] + private var locallyUnlockedAllUserIds: Set = [] + private var localUnlockSuppressionExpirations: [String: Date] = [:] + private var allowDisposable: Disposable? + private var observers: [NSObjectProtocol] = [] + private var isStarted = false + + private init() { + if let data = UserDefaults.standard.data(forKey: pendingReceiptsKey), + let receipts = try? JSONDecoder().decode([PendingAppUnlockReceipt].self, from: data) { + pendingReceipts = receipts + } else { + pendingReceipts = [] + } + for receipt in pendingReceipts { + restoreLocalUnlockSuppression(from: receipt) + } + } + + func start() { + performOnMain { [weak self] in + guard let self, !self.isStarted else { return } + self.isStarted = true + self.observers.append( + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.checkOfflineUnlockForCurrentUser() + } + ) + self.observers.append( + NotificationCenter.default.addObserver( + forName: .RefreshUserConfigNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.checkOfflineUnlockForCurrentUser() + } + ) + self.checkOfflineUnlockForCurrentUser() + self.flushPendingReceipts() + } + } + + func checkOfflineUnlockForCurrentUser() { + performOnMain { [weak self] in + guard let self else { return } + let userId = AppContextManager.shared.userId.trimmed + guard !userId.isEmpty else { return } + + if self.observedUserId != userId { + self.observedUserId = userId + self.completedAllowUserIds.remove(userId) + } + guard !self.completedAllowUserIds.contains(userId) else { return } + + if !self.allowRequestUserId.isEmpty, self.allowRequestUserId != userId { + self.allowDisposable?.dispose() + self.allowDisposable = nil + self.allowRequestUserId = "" + } + guard self.allowRequestUserId.isEmpty else { return } + + self.allowRequestUserId = userId + self.allowDisposable = UserService.phoneUnlockAllow() + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] response in + guard let self else { return } + self.finishAllowRequest(for: userId) + guard AppContextManager.shared.userId.trimmed == userId else { + self.checkOfflineUnlockForCurrentUser() + return + } + guard response.code == "0" else { + print("[AppUnlock] unlock allow failed: \(response.message ?? "unknown error")") + return + } + self.completedAllowUserIds.insert(userId) + for record in response.model?.locks ?? [] { + self.applyUnlockAndEnqueueReceipt(record: record, userId: userId) + } + }, onError: { [weak self] error in + self?.finishAllowRequest(for: userId) + print("[AppUnlock] unlock allow request failed: \(error.gatewayMessage ?? error.localizedDescription)") + }) + } + } + + func accountDidLogout(userId: String) { + performOnMain { [weak self] in + guard let self else { return } + let userId = userId.trimmed + self.completedAllowUserIds.remove(userId) + if self.allowRequestUserId == userId { + self.allowDisposable?.dispose() + self.allowDisposable = nil + self.allowRequestUserId = "" + } + if self.observedUserId == userId { + self.observedUserId = "" + } + self.locallyUnlockedTokensByUserId.removeValue(forKey: userId) + self.locallyUnlockedAllUserIds.remove(userId) + self.localUnlockSuppressionExpirations.removeValue(forKey: userId) + } + } + + @discardableResult + func handleIncoming(topic: String, payload: String?) -> Bool { + guard let payload, + let data = payload.data(using: .utf8), + let message = try? JSONDecoder().decode(MqttIncomingMessage.self, from: data), + message.type == MqttType.appUnlock.rawValue else { return false } + + print("📩 收到消息 -> 主题:\(topic),内容:\(payload)") + performOnMain { [weak self] in + guard let self else { return } + guard let body = message.data?.app_unlock else { return } + NotificationCenter.default.post(name: .lockDistractAppsDidChange, object: nil) + guard !self.consumeExpectedOutgoingEcho(payload) else { return } + + let currentUserId = AppContextManager.shared.userId.trimmed + let topicUserId = topic.replacingOccurrences(of: "smartdrive/", with: "").trimmed + let targetUserId = body.user_id?.trimmed ?? "" + guard !currentUserId.isEmpty, + targetUserId == currentUserId || (targetUserId.isEmpty && topicUserId == currentUserId) else { return } + + var record = PhoneLockRecord(mqtt: body) + record.userId = currentUserId + self.applyUnlockAndEnqueueReceipt(record: record, userId: currentUserId) + } + return true + } + + func applyUnlock(tokens: [String]) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.applyUnlock(tokens: tokens) + } + return + } + + lockStateRevision &+= 1 + rememberLocalUnlock(tokens: tokens, userId: AppContextManager.shared.userId.trimmed) + if #available(iOS 16.0, *) { + AppRestrictManager.shared.applyRemoteUnlock(tokens: tokens) + } + if tokens.isEmpty { + PhoneLockSession.currentLocks = [] + } else { + let tokenSet = Set(tokens) + PhoneLockSession.currentLocks = PhoneLockSession.currentLocks.compactMap { record in + var updated = record + updated.tokens = record.tokens.filter { !tokenSet.contains($0) } + return updated.tokens.isEmpty ? nil : updated + } + } + if PhoneLockSession.currentLocks.isEmpty { + LockedAppPopView.dismiss() + } + } + + func registerIncomingLock(tokens: [String]) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.registerIncomingLock(tokens: tokens) + } + return + } + + let userId = AppContextManager.shared.userId.trimmed + guard !userId.isEmpty else { return } + lockStateRevision &+= 1 + locallyUnlockedAllUserIds.remove(userId) + guard var unlockedTokens = locallyUnlockedTokensByUserId[userId] else { return } + unlockedTokens.subtract(tokens) + if unlockedTokens.isEmpty { + locallyUnlockedTokensByUserId.removeValue(forKey: userId) + } else { + locallyUnlockedTokensByUserId[userId] = unlockedTokens + } + if locallyUnlockedTokensByUserId[userId] == nil { + localUnlockSuppressionExpirations.removeValue(forKey: userId) + } + } + + func currentLockStateRevision() -> UInt64 { + if !Thread.isMainThread { + assertionFailure("Lock state revision must be read on the main thread") + } + return lockStateRevision + } + + func canApplyLockResponse(startedAt revision: UInt64, userId: String) -> Bool { + if !Thread.isMainThread { + assertionFailure("Lock response must be checked on the main thread") + } + return revision == lockStateRevision + && userId == AppContextManager.shared.userId.trimmed + } + + func locksForRestoration( + _ locks: [PhoneLockRecord], + startedAt revision: UInt64, + userId: String + ) -> [PhoneLockRecord]? { + guard canApplyLockResponse(startedAt: revision, userId: userId) else { return nil } + removeExpiredLocalUnlockSuppression(for: userId) + let pendingScope = pendingUnlockScope(for: userId) + if locallyUnlockedAllUserIds.contains(userId) || pendingScope.unlockAll { + return [] + } + let unlockedTokens = locallyUnlockedTokensByUserId[userId, default: []] + .union(pendingScope.tokens) + guard !unlockedTokens.isEmpty else { return locks } + return locks.compactMap { record in + var filteredRecord = record + filteredRecord.tokens = record.tokens.filter { !unlockedTokens.contains($0) } + return filteredRecord.tokens.isEmpty ? nil : filteredRecord + } + } + + func mqttDidConnect() { + performOnMain { [weak self] in + self?.checkOfflineUnlockForCurrentUser() + self?.flushPendingReceipts() + } + } + + func mqttDidDisconnect() { + performOnMain { [weak self] in + self?.inFlightReceipts.removeAll() + self?.expectedOutgoingEchoes.removeAll() + } + } + + func mqttDidAcknowledgePublish(id: UInt16) { + performOnMain { [weak self] in + guard let self, let receipt = self.inFlightReceipts.removeValue(forKey: id) else { return } + self.pendingReceipts.removeAll { $0 == receipt } + self.savePendingReceipts() + self.flushPendingReceipts() + } + } + + private func applyUnlockAndEnqueueReceipt(record: PhoneLockRecord, userId: String) { + applyUnlock(tokens: record.tokens) + guard let payload = makeReceiptPayload(record: record, userId: userId) else { + print("[AppUnlock] failed to encode unlock receipt") + return + } + let receipt = PendingAppUnlockReceipt(userId: userId, payload: payload) + pendingReceipts.append(receipt) + savePendingReceipts() + flushPendingReceipts() + } + + private func makeReceiptPayload(record: PhoneLockRecord, userId: String) -> String? { + let os = record.os.trimmed.isEmpty ? "ios" : record.os.trimmed + let payload = MqttAppUnlockReceiptPayload( + type: MqttType.appUnlock.rawValue, + data: MqttAppUnlockReceiptData( + app_unlock: MqttAppUnlockReceiptBody( + os: os, + group_key: record.groupKey, + group_name: record.groupName, + user_id: userId, + token: record.tokens + ) + ), + extra: "" + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(payload) else { return nil } + return String(data: data, encoding: .utf8) + } + + private func flushPendingReceipts() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.flushPendingReceipts() + } + return + } + + let userId = AppContextManager.shared.userId.trimmed + guard MQTTService.shared.isConnected, !userId.isEmpty else { return } + let inFlight = Set(inFlightReceipts.values.map(\.identifier)) + for receipt in pendingReceipts where receipt.userId == userId && !inFlight.contains(receipt.identifier) { + registerExpectedOutgoingEcho(receipt.payload) + let messageId = MQTTService.shared.publish( + topic: "smartdrive/\(userId)", + message: receipt.payload, + qos: .qos1 + ) + guard messageId >= 0, messageId <= Int(UInt16.max) else { + cancelExpectedOutgoingEcho(receipt.payload) + break + } + inFlightReceipts[UInt16(messageId)] = receipt + } + } + + private func finishAllowRequest(for userId: String) { + guard allowRequestUserId == userId else { return } + allowRequestUserId = "" + allowDisposable = nil + } + + private func savePendingReceipts() { + guard let data = try? JSONEncoder().encode(pendingReceipts) else { return } + UserDefaults.standard.set(data, forKey: pendingReceiptsKey) + } + + private func rememberLocalUnlock(tokens: [String], userId: String) { + guard !userId.isEmpty else { return } + localUnlockSuppressionExpirations[userId] = Date().addingTimeInterval(120) + if tokens.isEmpty { + locallyUnlockedAllUserIds.insert(userId) + locallyUnlockedTokensByUserId.removeValue(forKey: userId) + return + } + locallyUnlockedTokensByUserId[userId, default: []].formUnion(tokens) + } + + private func removeExpiredLocalUnlockSuppression(for userId: String) { + guard let expiresAt = localUnlockSuppressionExpirations[userId], + expiresAt <= Date() else { return } + locallyUnlockedTokensByUserId.removeValue(forKey: userId) + locallyUnlockedAllUserIds.remove(userId) + localUnlockSuppressionExpirations.removeValue(forKey: userId) + } + + private func pendingUnlockScope(for userId: String) -> (unlockAll: Bool, tokens: Set) { + var unlockAll = false + var tokens: Set = [] + for receipt in pendingReceipts where receipt.userId == userId { + guard let data = receipt.payload.data(using: .utf8), + let payload = try? JSONDecoder().decode(MqttAppUnlockReceiptPayload.self, from: data) else { continue } + let receiptTokens = payload.data.app_unlock.token + if receiptTokens.isEmpty { + unlockAll = true + } else { + tokens.formUnion(receiptTokens) + } + } + return (unlockAll, tokens) + } + + private func restoreLocalUnlockSuppression(from receipt: PendingAppUnlockReceipt) { + guard let data = receipt.payload.data(using: .utf8), + let payload = try? JSONDecoder().decode(MqttAppUnlockReceiptPayload.self, from: data) else { return } + rememberLocalUnlock( + tokens: payload.data.app_unlock.token, + userId: receipt.userId + ) + } + + private func registerExpectedOutgoingEcho(_ payload: String) { + removeExpiredOutgoingEchoes() + var expected = expectedOutgoingEchoes[payload] + ?? ExpectedAppUnlockEcho(count: 0, expiresAt: .distantPast) + expected.count += 1 + expected.expiresAt = Date().addingTimeInterval(60) + expectedOutgoingEchoes[payload] = expected + } + + private func cancelExpectedOutgoingEcho(_ payload: String) { + guard var expected = expectedOutgoingEchoes[payload] else { return } + expected.count -= 1 + if expected.count > 0 { + expectedOutgoingEchoes[payload] = expected + } else { + expectedOutgoingEchoes.removeValue(forKey: payload) + } + } + + private func consumeExpectedOutgoingEcho(_ payload: String) -> Bool { + removeExpiredOutgoingEchoes() + guard expectedOutgoingEchoes[payload] != nil else { return false } + cancelExpectedOutgoingEcho(payload) + return true + } + + private func removeExpiredOutgoingEchoes() { + let now = Date() + expectedOutgoingEchoes = expectedOutgoingEchoes.filter { $0.value.expiresAt > now } + } + + private func performOnMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } } /// 手机信息上报数据 @@ -151,7 +675,7 @@ final class MQTTService: NSObject { static let shared = MQTTService() private var mqtt: CocoaMQTT5? - private var isConnected = false + private(set) var isConnected = false // MARK: - 连接状态回调 var onConnected: (() -> Void)? @@ -246,6 +770,7 @@ final class MQTTService: NSObject { mqtt?.disconnect() isConnected = false invalidatePhoneReportTimer() + AppUnlockCoordinator.shared.mqttDidDisconnect() } // MARK: - 切换用户 @@ -609,7 +1134,12 @@ extension MQTTService: CocoaMQTT5Delegate { print("MQTT5 connected: \(ack)") // 订阅基础 topic,接收 signIn/join/leave 等非位置消息 subscribe(topic: topic) + let userId = AppContextManager.shared.userId.trimmed + if !userId.isEmpty { + subscribe(topic: "\(topic)\(userId)") + } onConnected?() + AppUnlockCoordinator.shared.mqttDidConnect() reportPhoneUsageIfNeeded() reportPhoneIfNeeded() } @@ -620,11 +1150,15 @@ extension MQTTService: CocoaMQTT5Delegate { func mqtt5(_ mqtt5: CocoaMQTT5, didPublishAck id: UInt16, pubAckData: MqttDecodePubAck?) { print("MQTT5 publish ack: \(id)") + AppUnlockCoordinator.shared.mqttDidAcknowledgePublish(id: id) } func mqtt5(_ mqtt5: CocoaMQTT5, didPublishRec id: UInt16, pubRecData: MqttDecodePubRec?) {} func mqtt5(_ mqtt5: CocoaMQTT5, didReceiveMessage message: CocoaMQTT5Message, id: UInt16, publishData: MqttDecodePublish?) { + if AppUnlockCoordinator.shared.handleIncoming(topic: message.topic, payload: message.string) { + return + } // 优先 topic 专用回调 if let cb = topicCallbacks[message.topic] { cb(message) @@ -657,6 +1191,7 @@ extension MQTTService: CocoaMQTT5Delegate { isConnected = false invalidatePhoneReportTimer() print("MQTT5 disconnected: \(err?.localizedDescription ?? "")") + AppUnlockCoordinator.shared.mqttDidDisconnect() onDisconnected?() } } diff --git a/QuickLocation/Manager/URL/URLManager.swift b/QuickLocation/Manager/URL/URLManager.swift index 63469533..eb3362c9 100644 --- a/QuickLocation/Manager/URL/URLManager.swift +++ b/QuickLocation/Manager/URL/URLManager.swift @@ -86,9 +86,44 @@ extension DefaultsKeys { case -1: // UAT return "https://jsapi.zuom8.cn/" default: // SIT - return "http://172.16.10.21:9243/" + return "http://172.16.10.20:9243/" } } + + static let presetServers: [(title: String, url: String, env: Int)] = [ + ("开发", "http://172.16.10.20: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 "" } + if !url.contains("://") { + url = "http://" + url + } + if !url.hasSuffix("/") { + url += "/" + } + return url + } + + static func env(for url: String) -> Int { + let normalized = normalizedServerURL(url) + if let preset = presetServers.first(where: { normalizedServerURL($0.url) == normalized }) { + return preset.env + } + return 99 + } // MARK: - 大对象服务器地址 func uploadServerURL() -> String { diff --git a/QuickLocation/Model/GroupModel.swift b/QuickLocation/Model/GroupModel.swift index c0ae5930..3612ca64 100644 --- a/QuickLocation/Model/GroupModel.swift +++ b/QuickLocation/Model/GroupModel.swift @@ -207,6 +207,12 @@ struct GroupInfoModel: Mappable, Equatable { let name = group_template?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return name.isEmpty ? "未知类型" : name } + + var isCoupleGroup: Bool { + if groupTemplateName.contains("情侣") { return true } + if let limit = group_template?.limit, limit == 2 { return true } + return false + } var groupIconURL: String { group_template?.icon.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } @@ -303,19 +309,23 @@ struct GroupMemberModel: Mappable, Equatable { var user_id: String = "" /// 头像,对应本地图标 var head_pic: String = "1" + var avater: String = "" + var displayHeadPic: String { + HeadPic.resolved(head_pic, avater) + } /// 用户头像 var userIcon: UIImage { - let iconName = head_pic.trimmingCharacters(in: .whitespacesAndNewlines) - guard !iconName.isEmpty, - let image = UIImage(named: "UserIcon/\(iconName)") else { - return UIImage(named: "UserIcon/1") ?? UIImage() - } - return image + HeadPic.image(for: displayHeadPic) } /// 昵称 var nick_name: String = "" /// 备注 var remark: String = "" + + var showName: String { + remark.isEmpty ? nick_name : remark + } + /// 会员等级 1普通 2白银 3永久 4钻石 var level: Int = 1 var vipIcon: UIImage { @@ -378,7 +388,11 @@ struct GroupMemberModel: Mappable, Equatable { mutating func mapping(map: Map) { user_id <- map["user_id"] - head_pic <- map["head_pic"] + head_pic <- (map["head_pic"], kFlexString) + avater <- (map["avater"], kFlexString) + if avater.isEmpty { + avater <- (map["avatar"], kFlexString) + } level <- (map["level"], kStrTransformInt) nick_name <- map["nick_name"] remark <- map["remark"] diff --git a/QuickLocation/Model/PigeonModel.swift b/QuickLocation/Model/PigeonModel.swift index 61575d96..37cee2a5 100644 --- a/QuickLocation/Model/PigeonModel.swift +++ b/QuickLocation/Model/PigeonModel.swift @@ -314,8 +314,8 @@ struct PigeonSentMessage: Mappable { image: localTemplateImage, mediaURL: mediaURL, localAudioData: localVoiceData, - senderAvatar: from_user?.avatarImage, - receiverAvatars: to_user.map(\.avatarImage), + senderHeadPic: from_user?.displayHeadPic ?? "", + receiverHeadPics: to_user.map(\.displayHeadPic), duration: localVoiceTemplate?.duration ?? 0 ) } @@ -350,15 +350,24 @@ struct PigeonSentUser: Mappable { var nick_name: String = "" var remark: String = "" var head_pic: String = "" + var avater: String = "" var extra = MemberExtra() + var displayHeadPic: String { + HeadPic.resolved(head_pic, avater) + } + init?(map: Map) {} mutating func mapping(map: Map) { user_id <- map["user_id"] nick_name <- map["nick_name"] remark <- map["remark"] - head_pic <- map["head_pic"] + head_pic <- (map["head_pic"], kFlexString) + avater <- (map["avater"], kFlexString) + if avater.isEmpty { + avater <- (map["avatar"], kFlexString) + } extra <- (map["extra"], kMemberExtraTransform) if extra.relation_idx.isEmpty { extra.relation_idx <- (map["relation_idx"], kMemberRelationIndexTransform) @@ -372,11 +381,6 @@ struct PigeonSentUser: Mappable { } var avatarImage: UIImage { - let iconName = head_pic.trimmingCharacters(in: .whitespacesAndNewlines) - guard !iconName.isEmpty, - let image = UIImage(named: "UserIcon/\(iconName)") else { - return UIImage(named: "UserIcon/1") ?? UIImage() - } - return image + HeadPic.image(for: displayHeadPic) } } diff --git a/QuickLocation/Plugin/ImagePlugin.swift b/QuickLocation/Plugin/ImagePlugin.swift index 366fab52..a9fc242b 100644 --- a/QuickLocation/Plugin/ImagePlugin.swift +++ b/QuickLocation/Plugin/ImagePlugin.swift @@ -167,3 +167,83 @@ extension DLWrapper where Base: UIButton { }) } } + +enum HeadPic { + static let placeholder = UIImage(named: "UserIcon/1") ?? UIImage() + + static func isRemote(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") + } + + static func resolved(_ values: String?...) -> String { + let trimmed = values + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if let remote = trimmed.first(where: { isRemote($0) }) { + return remote + } + return trimmed.first ?? "" + } + + static func localImage(_ value: String) -> UIImage { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return placeholder } + return UIImage(named: "UserIcon/\(trimmed)") ?? placeholder + } + + static func remoteURL(for value: String) -> URL? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard isRemote(trimmed) else { return nil } + return trimmed.imageURL ?? URL(string: trimmed) + } + + static func image(for value: String) -> UIImage { + guard let url = remoteURL(for: value) else { + return localImage(value) + } + let key = url.cacheKey + if let cached = ImageCache.default.retrieveImageInMemoryCache(forKey: key) { + return cached + } + return UIImage() + } + + static func load(_ value: String, completion: @escaping (UIImage) -> Void) { + guard let url = remoteURL(for: value) else { + completion(localImage(value)) + return + } + KingfisherManager.shared.retrieveImage(with: url) { result in + let image: UIImage + switch result { + case let .success(value): + image = value.image + case .failure: + image = UIImage() + } + DispatchQueue.main.async { + completion(image) + } + } + } +} + +extension UIImageView { + func setHeadPic(_ value: String?) { + let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + kf.cancelDownloadTask() + image = HeadPic.placeholder + return + } + if let url = HeadPic.remoteURL(for: trimmed) { + kf.cancelDownloadTask() + image = nil + kf.setImage(with: url, placeholder: nil) + } else { + kf.cancelDownloadTask() + image = HeadPic.localImage(trimmed) + } + } +} diff --git a/QuickLocation/Section/AppRestrict/AppRestrictVC.swift b/QuickLocation/Section/AppRestrict/AppRestrictVC.swift index b2453e13..75a3e33b 100644 --- a/QuickLocation/Section/AppRestrict/AppRestrictVC.swift +++ b/QuickLocation/Section/AppRestrict/AppRestrictVC.swift @@ -14,6 +14,8 @@ final class AppRestrictVC: BaseViewController { private var rootView: AppRestrictView! private var tokens: [ApplicationToken] = [] private var linkingToken: ApplicationToken? + private var lockedRecords: [PhoneLockRecord] = [] + private var deletingTokenStrings: Set = [] override func loadView() { rootView = AppRestrictView(frame: UIScreen.main.bounds) @@ -27,6 +29,12 @@ final class AppRestrictVC: BaseViewController { rootView.tableView.register(AppRestrictCell.self, forCellReuseIdentifier: AppRestrictCell.reuseId) bind() reload() + queryLockedApps() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + queryLockedApps() } private func bind() { @@ -36,6 +44,12 @@ final class AppRestrictVC: BaseViewController { rootView.addBtn.rx.tap.subscribe(onNext: { [weak self] in self?.addApps() }).disposed(by: disposeBag) + NotificationCenter.default.rx.notification(.appRestrictPairingDataDidChange) + .subscribe(onNext: { [weak self] _ in + self?.lockedRecords = [] + self?.reload() + }) + .disposed(by: disposeBag) } private func reload() { @@ -63,8 +77,6 @@ final class AppRestrictVC: BaseViewController { self?.reload() if addedApplications.isEmpty, !selection.categoryTokens.isEmpty { self?.showCategorySelectionNotice() - } else if !addedApplications.isEmpty { - self?.showApplicationTokens(addedApplications) } } present(presenter, animated: false) @@ -87,31 +99,6 @@ final class AppRestrictVC: BaseViewController { present(alert, animated: true) } - private func showApplicationTokens(_ tokens: Set) { - let values = tokens.compactMap { token in - AppRestrictTokenCodec.encode(token)?.base64EncodedString() - }.sorted() - guard !values.isEmpty else { - DLToast.show(text: "ApplicationToken 编码失败") - return - } - - let message = values.enumerated().map { index, value in - values.count == 1 ? value : "应用 \(index + 1):\n\(value)" - }.joined(separator: "\n\n") - let alert = UIAlertController( - title: "ApplicationToken", - message: message, - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "复制", style: .default) { _ in - UIPasteboard.general.string = values.joined(separator: "\n") - DLToast.show(text: "Token 已复制") - }) - alert.addAction(UIAlertAction(title: "关闭", style: .cancel)) - present(alert, animated: true) - } - private func openLink(for token: ApplicationToken) { linkingToken = token let vc = SelectActivityVC() @@ -125,14 +112,175 @@ final class AppRestrictVC: BaseViewController { ) self.linkingToken = nil self.reload() + self.syncPairedApp(token: token, item: item) } let nav = UINavigationController(rootViewController: vc) present(nav, animated: true) } + private func encodedTokenString(_ token: ApplicationToken) -> String? { + AppRestrictTokenCodec.encode(token)?.base64EncodedString() + } + + private func syncPairedApp(token: ApplicationToken, item: AppCatalogItem) { + guard let tokenString = encodedTokenString(token), !tokenString.isEmpty else { + DLToast.show(text: "应用 token 编码失败") + return + } + DLToast.showLoading() + resolveIconFileId(for: item) + .flatMap { fileId in + UserService.phoneLockApp(token: tokenString, icon: fileId) + } + .subscribe(onNext: { _ in + DLToast.show(text: "配对成功") + }, onError: { error in + DLToast.show(text: error.gatewayMessage ?? error.localizedDescription) + }) + .disposed(by: disposeBag) + } + + private func resolveIconFileId(for item: AppCatalogItem) -> Observable { + UserService.queryPhoneUsageIcon(package: item.name) + .catch { error -> Observable in + if Self.isTransportError(error) { + return .error(error) + } + return .just(.empty) + } + .flatMap { [weak self] response -> Observable in + let existing = response.fileId.trimmingCharacters(in: .whitespacesAndNewlines) + if !existing.isEmpty { + return .just(existing) + } + guard let self else { + return .error(NSError(domain: "AppRestrict", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "配对已取消" + ])) + } + return self.uploadAndRegisterIcon(for: item) + } + } + + private func uploadAndRegisterIcon(for item: AppCatalogItem) -> Observable { + jpegData(for: item) + .flatMap { data in + UploadService.upload(data, kind: .jpeg, scene: "app_icon") + } + .flatMap { uploadedId in + UserService.savePhoneUsageIcon(package: item.name, icon: uploadedId) + .map { response in + let registered = response.fileId.trimmingCharacters(in: .whitespacesAndNewlines) + return registered.isEmpty ? uploadedId : registered + } + .catchAndReturn(uploadedId) + } + } + + private func jpegData(for item: AppCatalogItem) -> Observable { + if let urlString = item.iconURL?.trimmingCharacters(in: .whitespacesAndNewlines), + let url = URL(string: urlString) { + return Observable.create { observer in + let task = URLSession.shared.dataTask(with: url) { data, _, error in + if let error { + DispatchQueue.main.async { + observer.onError(error) + } + return + } + guard let data, + let image = UIImage(data: data), + let jpeg = image.jpegData(compressionQuality: 0.8) else { + DispatchQueue.main.async { + observer.onError(NSError(domain: "AppRestrict", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "下载应用图标失败" + ])) + } + return + } + DispatchQueue.main.async { + observer.onNext(jpeg) + observer.onCompleted() + } + } + task.resume() + return Disposables.create { task.cancel() } + } + } + + guard let image = AppCatalogStore.image(for: item), + let jpeg = image.jpegData(compressionQuality: 0.8) else { + return Observable.error(NSError(domain: "AppRestrict", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "缺少应用图标" + ])) + } + return .just(jpeg) + } + + private static func isTransportError(_ error: Error) -> Bool { + let nsError = error.underlyingError ?? error as NSError + return nsError.domain == NSURLErrorDomain + } + + private func queryLockedApps() { + let userId = AppContextManager.shared.userId.trimmed + let lockStateRevision = AppUnlockCoordinator.shared.currentLockStateRevision() + UserService.phoneLocked(os: "ios") + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] response in + guard let locks = AppUnlockCoordinator.shared.locksForRestoration( + response.model?.locks ?? [], + startedAt: lockStateRevision, + userId: userId + ) else { return } + self?.lockedRecords = locks + if !locks.isEmpty { + PhoneLockSession.currentLocks = locks + } + }) + .disposed(by: disposeBag) + } + + private func lockRecord(for token: ApplicationToken) -> PhoneLockRecord? { + guard let encoded = encodedTokenString(token), !encoded.isEmpty else { return nil } + return lockedRecords.first { $0.tokens.contains(encoded) } + ?? PhoneLockSession.currentLocks.first { $0.tokens.contains(encoded) } + } + private func deleteApp(_ token: ApplicationToken) { - AppRestrictManager.shared.removeApplication(token) - reload() + let records = lockedRecords.isEmpty ? PhoneLockSession.currentLocks : lockedRecords + if !records.isEmpty { + let record = lockRecord(for: token) ?? records.first + if let record { + LockedAppPopView.show(record) + return + } + } + guard let tokenString = encodedTokenString(token), !tokenString.isEmpty else { + DLToast.show(text: "应用 token 编码失败") + return + } + guard !deletingTokenStrings.contains(tokenString) else { return } + + deletingTokenStrings.insert(tokenString) + DLToast.showLoading() + UserService.phoneLockAppsDelete(tokens: [tokenString]) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] response in + self?.deletingTokenStrings.remove(tokenString) + guard response.code == "0" else { + let message = response.message?.trimmingCharacters(in: .whitespacesAndNewlines) + let displayMessage = message.flatMap { $0.isEmpty ? nil : $0 } ?? "删除失败,请稍后重试" + DLToast.show(text: displayMessage) + return + } + AppRestrictManager.shared.removeApplication(token) + DLToast.show(text: "删除成功") + }, onError: { [weak self] error in + self?.deletingTokenStrings.remove(tokenString) + DLToast.show(text: error.gatewayMessage ?? error.localizedDescription) + }) + .disposed(by: UserService.disposeBag) } } diff --git a/QuickLocation/Section/Explore/FeatureIntroView.swift b/QuickLocation/Section/Explore/FeatureIntroView.swift index 8042c283..7b2d848a 100644 --- a/QuickLocation/Section/Explore/FeatureIntroView.swift +++ b/QuickLocation/Section/Explore/FeatureIntroView.swift @@ -96,23 +96,6 @@ final class FeatureIntroView: UIView { headerBackgroundView.addSubview(headerBgImg) headerBackgroundView.addSubview(cycleScrollView) - titleLineView.backgroundColor = UIColor(hexStr: "#253B55") - titleLineView.layer.cornerRadius = 1.5 - titleLineView.translatesAutoresizingMaskIntoConstraints = false - - titleLabel.text = "一键锁机" - titleLabel.textColor = UIColor(hexStr: "#253B55") - titleLabel.font = FontManager.ziHunBianHei(28) - titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - - titleContainer.axis = .horizontal - titleContainer.alignment = .center - titleContainer.spacing = 3 - titleContainer.addArrangedSubview(titleLineView) - titleContainer.addArrangedSubview(titleLabel) - addSubview(titleContainer) - titleContainer.translatesAutoresizingMaskIntoConstraints = false - sheetView.backgroundColor = .white sheetView.layer.cornerRadius = 30 sheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] @@ -157,7 +140,7 @@ final class FeatureIntroView: UIView { headerBackgroundView.layoutChain .edges(excludingEdge: .bottom) - .heightToWidth(230 / 375) + .heightToWidth(190 / 375) headerBgImg.layoutChain.edges() @@ -168,11 +151,6 @@ final class FeatureIntroView: UIView { .edges(excludingEdge: .top) NSLayoutConstraint.activate([ - titleLineView.widthAnchor.constraint(equalToConstant: 24), - titleLineView.heightAnchor.constraint(equalToConstant: 3), - titleContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 75), - titleContainer.topAnchor.constraint(equalTo: topAnchor, constant: 91), - scrollView.topAnchor.constraint(equalTo: sheetView.topAnchor), scrollView.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor), diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift index 014c3dfe..973d6cde 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift @@ -258,6 +258,13 @@ final class GroupChatVC: BaseViewController { }) .disposed(by: disposeBag) + NotificationCenter.default.rx.notification(.RefreshUserConfigNotification) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] _ in + self?.requestGroupInfoByKey() + }) + .disposed(by: disposeBag) + let emojiItems = UIView.emojiFileNames.map { $0 } Observable.just([SectionModel(model: "", items: emojiItems)]) .bind(to: rootView.emojiCollectionView.rx.items(dataSource: emojiDataSource)) @@ -514,7 +521,7 @@ final class GroupChatVC: BaseViewController { } private func showMentionPicker() { - mentionRows = [(nil, "所有人")] + viewModel.mentionCandidates().map { ($0.user_id, $0.nick_name) } + mentionRows = [(nil, "所有人")] + viewModel.mentionCandidates().map { ($0.user_id, $0.showName) } rootView.mentionTableView.reloadData() rootView.mentionPickerView.isHidden = false isMentionPickerVisible = true @@ -1120,6 +1127,7 @@ extension GroupChatVC { isSelf: true, senderId: AppContextManager.shared.userId, avatar: viewModel.getUserAvatar(id: AppContextManager.shared.userId), + headPic: viewModel.getUserHeadPic(id: AppContextManager.shared.userId), senderName: AppContextManager.shared.name, content: "", voiceUrl: "", @@ -1306,8 +1314,7 @@ private final class ChatCurrentLocationAnnotationView: MAAnnotationView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure() { - let avatar = AppContextManager.shared.avaterIcon - avatarImageView.image = avatar.size == .zero ? UIImage(named: "Common/default_avatar") : avatar + avatarImageView.setHeadPic(AppContextManager.shared.head_pic) } } #endif diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift index 667643fc..4a7d9094 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift @@ -54,6 +54,7 @@ struct ChatMessage { let isSelf: Bool let senderId: String let avatar: UIImage + let headPic: String let senderName: String let content: String let voiceUrl: String @@ -72,6 +73,7 @@ struct ChatMessage { var location: ChatLocationPayload? = nil func with(avatar: UIImage? = nil, + headPic: String? = nil, showTime: Bool? = nil, isUploading: Bool? = nil, imageUrl: String? = nil, @@ -84,6 +86,7 @@ struct ChatMessage { isSelf: isSelf, senderId: senderId, avatar: avatar ?? self.avatar, + headPic: headPic ?? self.headPic, senderName: senderName, content: content, voiceUrl: voiceUrl, @@ -1174,7 +1177,7 @@ class TextSendMsgCell: UITableViewCell { messageId = msg.id timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil - avatarView.image = msg.avatar + avatarView.setHeadPic(msg.headPic) senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) contentLabel.attributedText = msg.attributedContent(isOutgoing: true) let hasQuote = msg.quotePreview != nil @@ -1313,7 +1316,7 @@ class TextReceivedMsgCell: UITableViewCell { senderName = msg.senderName timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil - avatarView.image = msg.avatar + avatarView.setHeadPic(msg.headPic) senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) contentLabel.attributedText = msg.attributedContent(isOutgoing: false) let hasQuote = msg.quotePreview != nil @@ -1556,7 +1559,7 @@ final class EmojiSendMsgCell: UITableViewCell { func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil - avatarView.image = msg.avatar + avatarView.setHeadPic(msg.headPic) senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0 guard Self.emojiFileNames.indices.contains(index) else { @@ -1669,7 +1672,7 @@ final class EmojiReceivedMsgCell: UITableViewCell { func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil - avatarView.image = msg.avatar + avatarView.setHeadPic(msg.headPic) senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0 guard Self.emojiFileNames.indices.contains(index) else { @@ -1844,7 +1847,7 @@ final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView { func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil - avatarView.image = msg.avatar + avatarView.setHeadPic(msg.headPic) senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) let dur = msg.content.int / 1000 durationLabel.text = dur > 0 ? "\(dur)''" : "" @@ -1888,7 +1891,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil - avatarView.image = msg.avatar + avatarView.setHeadPic(msg.headPic) senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) let dur = msg.content.int / 1000 durationLabel.text = dur > 0 ? "\(dur)''" : "" @@ -2046,7 +2049,7 @@ class ChatImageMsgCell: UITableViewCell { configuredId = msg.id timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil - avatarView.image = msg.avatar + avatarView.setHeadPic(msg.headPic) senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) applyPhotoSize(ChatImageLayout.messageSize(width: msg.imageWidth, height: msg.imageHeight)) loadPhoto(url: msg.imageUrl, messageId: msg.id) @@ -2269,7 +2272,7 @@ class ChatLocationMsgCell: UITableViewCell { func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil - avatarView.image = msg.avatar + avatarView.setHeadPic(msg.headPic) senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) let location = msg.location ?? ChatLocationPayload(name: msg.content, address: "", latitude: 0, longitude: 0) titleLabel.text = location.name.isEmpty ? "位置" : location.name diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift index a61c8e89..32fe0aa4 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift @@ -177,16 +177,45 @@ final class GroupChatViewModel { // MARK: - Avatar private var avatarCache: [String: UIImage] = [:] + private var avatarSources: [String: String] = [:] func buildAvatarCache() { var cache: [String: UIImage] = [:] + var sources: [String: String] = [:] for member in memberList { - cache[member.user_id] = member.userIcon + let headPic = chatHeadPic(for: member) + cache[member.user_id] = HeadPic.image(for: headPic) + sources[member.user_id] = headPic } avatarCache = cache + avatarSources = sources + for (userId, headPic) in sources { + loadAvatar(headPic, userId: userId) + } refreshMessageMetadata() } + private func chatHeadPic(for member: GroupMemberModel) -> String { + let headPic = member.head_pic.trimmingCharacters(in: .whitespacesAndNewlines) + if !headPic.isEmpty { return headPic } + return member.avater.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func currentUserHeadPic() -> String { + guard let account = AppContextManager.shared.account else { return "" } + let headPic = account.head_pic.trimmingCharacters(in: .whitespacesAndNewlines) + if !headPic.isEmpty { return headPic } + return account.avater?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + + private func loadAvatar(_ headPic: String, userId: String) { + HeadPic.load(headPic) { [weak self] image in + guard let self, self.avatarSources[userId] == headPic else { return } + self.avatarCache[userId] = image + self.refreshMessageMetadata() + } + } + private func refreshMessageMetadata() { var items = messagesSubject.value var didChange = false @@ -213,23 +242,38 @@ final class GroupChatViewModel { } private func updateAvatar(_ msg: inout ChatMessage) -> Bool { - guard let cached = avatarCache[msg.senderId], cached != msg.avatar else { return false } - msg = msg.with(avatar: cached) + let pic = getUserHeadPic(id: msg.senderId) + let cached = avatarCache[msg.senderId] + let avatarChanged = cached != nil && cached != msg.avatar + let picChanged = pic != msg.headPic + guard avatarChanged || picChanged else { return false } + msg = msg.with(avatar: cached, headPic: pic) return true } func getUserAvatar(id: String) -> UIImage { - if let image = avatarCache[id] { return image } - if let member = memberList.first(where: { $0.user_id == id }) { - let image = member.userIcon - avatarCache[id] = image - return image + let headPic = getUserHeadPic(id: id) + if avatarSources[id] == headPic, let image = avatarCache[id] { return image } + let image = HeadPic.image(for: headPic) + avatarSources[id] = headPic + avatarCache[id] = image + loadAvatar(headPic, userId: id) + return image + } + + func getUserHeadPic(id: String) -> String { + if id == AppContextManager.shared.userId { + let mine = currentUserHeadPic() + if !mine.isEmpty { return mine } } - return UIImage(named: "UserIcon/1") ?? UIImage() + if let member = memberList.first(where: { $0.user_id == id }) { + return chatHeadPic(for: member) + } + return "" } func getUserNickName(id: String) -> String { - memberList.first { id == $0.user_id }?.nick_name ?? "" + memberList.first { id == $0.user_id }?.showName ?? "" } func isCircleOwner(id: String) -> Bool { @@ -803,6 +847,7 @@ final class GroupChatViewModel { isSelf: isSelf, senderId: sendID, avatar: getUserAvatar(id: sendID), + headPic: getUserHeadPic(id: sendID), senderName: senderName, content: content, voiceUrl: voiceUrl, diff --git a/QuickLocation/Section/Group/GroupIMService.swift b/QuickLocation/Section/Group/GroupIMService.swift index 0d18eb83..b4acf878 100644 --- a/QuickLocation/Section/Group/GroupIMService.swift +++ b/QuickLocation/Section/Group/GroupIMService.swift @@ -7,6 +7,7 @@ import Foundation import OpenIMSDK +import RxSwift final class GroupIMService { @@ -16,6 +17,7 @@ final class GroupIMService { private var isLogining = false /// 登录进行中时排队的回调,登录结束统一回调,避免并发重复登录 private var pendingLoginCompletions: [(Bool) -> Void] = [] + private let disposeBag = DisposeBag() private init() {} @@ -38,22 +40,17 @@ final class GroupIMService { } // MARK: - Login - /// 确保已登录(幂等):已登录直接回调,否则发起登录;登录中则排队等待 + /// 确保已登录(幂等):已登录直接回调,否则拉 token 再登录;登录中则排队等待 func ensureLogin(completion: @escaping (Bool) -> Void) { - if OIMManager.manager.getLoginStatus() == .logged { - completion(true) - return - } login(completion: completion) } func login(completion: @escaping (Bool) -> Void) { - // 已登录,直接成功 if OIMManager.manager.getLoginStatus() == .logged { completion(true) return } - guard let token = AppContextManager.shared.imToken, !token.isEmpty else { + if AppContextManager.shared.isGuest { completion(false) return } @@ -63,11 +60,34 @@ final class GroupIMService { return } - // 登录中:排队,复用同一次登录结果,避免重复发起 pendingLoginCompletions.append(completion) guard !isLogining else { return } isLogining = true + let existing = AppContextManager.shared.imToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !existing.isEmpty { + performSDKLogin(userId: userId, token: existing) + return + } + + UserService.imToken() + .subscribe(onNext: { [weak self] response in + guard let self else { return } + guard let data = response.data, + let token = data["token"] as? String, + !token.isEmpty else { + self.finishLogin(success: false) + return + } + AppContextManager.shared.imToken = token + self.performSDKLogin(userId: userId, token: token) + }, onError: { [weak self] _ in + self?.finishLogin(success: false) + }) + .disposed(by: disposeBag) + } + + private func performSDKLogin(userId: String, token: String) { OIMManager.manager.login(userId, token: token) { [weak self] _ in self?.finishLogin(success: true) } onFailure: { [weak self] code, msg in diff --git a/QuickLocation/Section/Group/GroupItineraryView.swift b/QuickLocation/Section/Group/GroupItineraryView.swift index 187246c5..030f5b50 100644 --- a/QuickLocation/Section/Group/GroupItineraryView.swift +++ b/QuickLocation/Section/Group/GroupItineraryView.swift @@ -261,7 +261,7 @@ extension GroupItineraryView: UITableViewDataSource, UITableViewDelegate { final class GroupItineraryCell: UITableViewCell { func configure(_ model: ScheduleModel, relationIdx: String) { - avatarImg.image = model.userIcon + avatarImg.setHeadPic(model.displayHeadPic) nameLab.text = model.nick_name.isEmpty ? "未命名用户" : model.nick_name relationIcon.configure(relationIdx: relationIdx) diff --git a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVM.swift b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVM.swift index 1f7014bc..a53d5f9a 100644 --- a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVM.swift +++ b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVM.swift @@ -85,7 +85,9 @@ final class GroupMemberListVM { private func publishDirectory() { let matches: (GroupMemberModel) -> Bool = { [searchKeyword] member in - searchKeyword.isEmpty || member.nick_name.localizedCaseInsensitiveContains(searchKeyword) + searchKeyword.isEmpty + || member.showName.localizedCaseInsensitiveContains(searchKeyword) + || member.nick_name.localizedCaseInsensitiveContains(searchKeyword) } var sections: [GroupMemberDirectorySection] = [] diff --git a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift index 36fbf462..ff253a36 100644 --- a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift +++ b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift @@ -159,8 +159,8 @@ final class GroupMemberDirectoryCell: UITableViewCell { showsSeparator: Bool, roundsTopCorners: Bool, roundsBottomCorners: Bool) { - avatarView.image = member.userIcon - nicknameLabel.text = member.nick_name.isEmpty ? "未知昵称" : member.nick_name + avatarView.setHeadPic(member.displayHeadPic) + nicknameLabel.text = member.showName.isEmpty ? "未知昵称" : member.showName separatorView.isHidden = !showsSeparator var corners: CACornerMask = [] @@ -258,8 +258,8 @@ final class GroupMemberListCell: UICollectionViewCell { } func configure(model: GroupMemberModel, isCurrentUser: Bool, isSelected: Bool) { - avatarImageView.image = model.userIcon - nameLabel.text = model.nick_name + avatarImageView.setHeadPic(model.displayHeadPic) + nameLabel.text = model.showName nameLabel.textColor = UIColor(hexStr: isCurrentUser ? "#16B3FF" : "#0F2846") selectionImageView.isHidden = !isSelected vipBackgroundView.isHidden = model.level == 1 diff --git a/QuickLocation/Section/Group/GroupMemberList/ItineraryTraceVC.swift b/QuickLocation/Section/Group/GroupMemberList/ItineraryTraceVC.swift index cee7cece..b9343cc4 100644 --- a/QuickLocation/Section/Group/GroupMemberList/ItineraryTraceVC.swift +++ b/QuickLocation/Section/Group/GroupMemberList/ItineraryTraceVC.swift @@ -81,7 +81,7 @@ class ItineraryTraceVC: BaseViewController { } private func populateData() { - rootView.titleLab.text = "\(memberModel.nick_name) 的驾驶详细信息" + rootView.titleLab.text = "\(memberModel.showName) 的驾驶详细信息" rootView.startTimeLab.text = model.start_time.isoStringToCustom(model.start_time, format: "HH:mm") rootView.endTimeLab.text = model.end_time.isoStringToCustom(model.end_time, format: "HH:mm") rootView.startAddressLab.text = model.start_address?.street @@ -485,7 +485,10 @@ extension ItineraryTraceVC: MAMapViewDelegate { var view = mapView.dequeueReusableAnnotationView(withIdentifier: id) if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) } else { view?.annotation = annotation } - view?.image = Self.playbackAvatarImage(memberModel.userIcon) + view?.image = Self.playbackAvatarImage(HeadPic.image(for: memberModel.displayHeadPic)) + HeadPic.load(memberModel.displayHeadPic) { [weak view] image in + view?.image = Self.playbackAvatarImage(image) + } view?.centerOffset = CGPoint(x: 0, y: -16) return view } diff --git a/QuickLocation/Section/Group/GroupMemberList/ScheduleRecordModel.swift b/QuickLocation/Section/Group/GroupMemberList/ScheduleRecordModel.swift index b70c32b4..2824c0ba 100644 --- a/QuickLocation/Section/Group/GroupMemberList/ScheduleRecordModel.swift +++ b/QuickLocation/Section/Group/GroupMemberList/ScheduleRecordModel.swift @@ -205,16 +205,23 @@ struct StayPoint: Mappable, Equatable { var lng: Double = 0 var start_time: Int64 = 0 var end_time: Int64 = 0 + var duration_minutes: Int = 0 var address: String = "" init?(map: Map) {} mutating func mapping(map: Map) { - lat <- map["latitude"] - lng <- map["longitude"] + lat <- map["location.latitude"] + lng <- map["location.longitude"] + if lat == 0 { lat <- map["latitude"] } + if lng == 0 { lng <- map["longitude"] } + duration_minutes <- map["duration_minutes"] start_time <- map["start_time"] end_time <- map["end_time"] - address <- map["address"] + address <- map["address.formatted_address"] + if address.isEmpty { + address <- map["address"] + } } } diff --git a/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift b/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift index 2deb4709..5f6270f1 100644 --- a/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift +++ b/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift @@ -453,8 +453,8 @@ private final class GroupSettingMemberCell: UIView { init(member: GroupMemberModel) { super.init(frame: .zero) setupUI() - avatarView.image = member.userIcon - nicknameLab.text = member.nick_name.isEmpty ? "未知昵称" : member.nick_name + avatarView.setHeadPic(member.displayHeadPic) + nicknameLab.text = member.showName.isEmpty ? "未知昵称" : member.showName } required init?(coder: NSCoder) { diff --git a/QuickLocation/Section/Group/GroupViewController.swift b/QuickLocation/Section/Group/GroupViewController.swift index 6d7ce91d..931f1eda 100644 --- a/QuickLocation/Section/Group/GroupViewController.swift +++ b/QuickLocation/Section/Group/GroupViewController.swift @@ -45,11 +45,19 @@ final class GroupViewController: BaseViewController { /// 进入页面加载 IM 会话:圈子集合已由业务接口给出,这里只补未读/最后一条 private func loadIMData() { + if AppContextManager.shared.isGuest { return } + DLToast.showLoading() GroupIMService.shared.ensureLogin { [weak self] success in - guard let self = self else { return } - guard success else { return } - self.setupIMListenersIfNeeded() - self.refreshIMData() + DispatchQueue.main.async { + guard let self else { return } + guard success else { + DLToast.dismiss() + DLToast.showError(text: "IM登录失败") + return + } + self.setupIMListenersIfNeeded() + self.refreshIMData() + } } } @@ -200,21 +208,29 @@ final class GroupViewController: BaseViewController { // MARK: - 行程页 private func refreshItineraryPage() { + let cached = AppContextManager.shared.defaultGroupKey + if !cached.isEmpty { + applyItineraryGroupKey(cached) + return + } GroupService.groupInfo().subscribe(onNext: { [weak self] response in guard let self = self, let model = response.model else { return } self.itineraryGroupModel = model let current = model.groups.first(where: { $0.group_key == model.default_group_key }) self.rootView.itineraryPage.updateGroupName(current?.name ?? "") - let groupKey = model.default_group_key - if self.itineraryGroupKey != groupKey { - self.itineraryGroupKey = groupKey - self.rootView.itineraryPage.updateMembers([]) - } - self.requestItinerarySchedules(groupKey: groupKey) - self.requestItineraryMembers(groupKey: groupKey) + self.applyItineraryGroupKey(model.default_group_key) }).disposed(by: disposeBag) } + private func applyItineraryGroupKey(_ groupKey: String) { + if itineraryGroupKey != groupKey { + itineraryGroupKey = groupKey + rootView.itineraryPage.updateMembers([]) + } + requestItinerarySchedules(groupKey: groupKey) + requestItineraryMembers(groupKey: groupKey) + } + private func requestItinerarySchedules(groupKey: String) { guard !groupKey.isEmpty else { itinerarySchedules = [] @@ -246,10 +262,20 @@ final class GroupViewController: BaseViewController { } private func showSwitchGroupPop() { - guard let groupModel = itineraryGroupModel else { - refreshItineraryPage() + if let groupModel = itineraryGroupModel { + presentItineraryGroupPicker(groupModel) return } + GroupService.groupInfo().subscribe(onNext: { [weak self] response in + guard let self, let model = response.model else { return } + self.itineraryGroupModel = model + let current = model.groups.first(where: { $0.group_key == model.default_group_key }) + self.rootView.itineraryPage.updateGroupName(current?.name ?? "") + self.presentItineraryGroupPicker(model) + }).disposed(by: disposeBag) + } + + private func presentItineraryGroupPicker(_ groupModel: GroupModel) { GroupListPopView.show(groupModel: groupModel) { [weak self] groupKey in guard let self = self, let key = groupKey else { return } GroupService.operate(opType: "setdefault", requestData: ["group_key": key]) diff --git a/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift b/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift index 4404d8db..7cf937f8 100644 --- a/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift +++ b/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift @@ -81,7 +81,7 @@ final class MemberInfoVC: BaseViewController { } }() rootView.renderRows([ - MemberInfoRow(title: "头像", value: "", kind: .avatar, avatarURL: member?.head_pic) { [weak self] in + MemberInfoRow(title: "头像", value: "", kind: .avatar, avatarURL: member?.displayHeadPic) { [weak self] in self?.editAvatar() }, MemberInfoRow(title: "昵称", value: member?.nick_name ?? "", kind: .disclosure) { [weak self] in @@ -90,8 +90,7 @@ final class MemberInfoVC: BaseViewController { MemberInfoRow(title: "性别", value: sexText, kind: .disclosure) { [weak self] in self?.editGender() }, - MemberInfoRow(title: "添加时间", value: joinText, kind: .text), - commonGroupsRow + MemberInfoRow(title: "添加时间", value: joinText, kind: .text) ]) } else { rootView.renderRows([ @@ -158,8 +157,8 @@ final class MemberInfoVC: BaseViewController { private func editAvatar() { let current = AppContextManager.shared.head_pic let vc = AvatarIconListVC(iconIndex: current) - vc.onSelectIcon = { [weak self] index in - self?.requestSetHeadPic(index: index) + vc.onSelectIcon = { [weak self] headPic in + self?.requestSetHeadPic(headPic) } navigationController?.pushViewController(vc, animated: true) } @@ -291,11 +290,11 @@ final class MemberInfoVC: BaseViewController { present(pop, animated: true) } - private func requestSetHeadPic(index: Int) { + private func requestSetHeadPic(_ headPic: String) { DLToast.showLoading() - UserService.setHeadPic(index: index).subscribe(onNext: { [weak self] _ in + UserService.setHeadPic(headPic: headPic).subscribe(onNext: { [weak self] _ in DLToast.show(text: "更换成功") - self?.applyLocalMemberChange(headPic: "\(index)", nickName: nil) + self?.applyLocalMemberChange(headPic: headPic, nickName: nil) self?.syncUserConfig() }, onError: { error in DLToast.dismiss() @@ -337,13 +336,17 @@ final class MemberInfoVC: BaseViewController { if let index = members.firstIndex(where: { $0.user_id == selectedUserId }) { if let headPic { members[index].head_pic = headPic + members[index].avater = HeadPic.isRemote(headPic) ? headPic : "" } if let nickName { members[index].nick_name = nickName } } if var account = AppContextManager.shared.account { - if let headPic { account.head_pic = headPic } + if let headPic { + account.head_pic = headPic + account.avater = HeadPic.isRemote(headPic) ? headPic : "" + } if let nickName { account.name = nickName } AppContextManager.shared.saveAccount(account) } diff --git a/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift b/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift index 2e16e46b..0694e14c 100644 --- a/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift +++ b/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift @@ -18,7 +18,7 @@ final class MemberInfoView: UIView { let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) cv.backgroundColor = .white cv.showsHorizontalScrollIndicator = false - cv.contentInset = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + cv.contentInset = UIEdgeInsets(top: 3, left: 6, bottom: 0, right: 6) cv.register(GroupMemberListCell.self) cv.layer.cornerRadius = 20 return cv @@ -154,13 +154,7 @@ final class MemberInfoRowView: UIControl { arrow.layoutChain.right(15).centerY().width(12).height(12) addSubview(avatar) avatar.layoutChain.rightToLeftOfView(arrow, offset: -8).centerY().width(28).height(28) - if let pic = row.avatarURL, !pic.isEmpty, let img = UIImage(named: "UserIcon/\(pic)") { - avatar.image = img - } else if let urlStr = row.avatarURL, let u = URL(string: urlStr), urlStr.hasPrefix("http") { - avatar.kf.setImage(with: u, placeholder: UIImage(named: "Common/default_avatar")) - } else { - avatar.image = UIImage(named: "Common/default_avatar") - } + avatar.setHeadPic(row.avatarURL) case .tag: setupGroupTags(row: row) } diff --git a/QuickLocation/Section/Group/RemoveMember/RemoveMemberView.swift b/QuickLocation/Section/Group/RemoveMember/RemoveMemberView.swift index e00c1fb8..21c76be6 100644 --- a/QuickLocation/Section/Group/RemoveMember/RemoveMemberView.swift +++ b/QuickLocation/Section/Group/RemoveMember/RemoveMemberView.swift @@ -184,8 +184,8 @@ final class RemoveMemberCell: UITableViewCell { isSelected: Bool, showsSeparator: Bool ) { - avatarImageView.image = model.userIcon - nameLab.text = model.nick_name + avatarImageView.setHeadPic(model.displayHeadPic) + nameLab.text = model.showName selectedBtn.isHidden = false selectedBtn.isSelected = isOwn ? false : isSelected selectedBtn.alpha = isOwn ? 0.45 : 1 diff --git a/QuickLocation/Section/Group/RemoveMember/RemoveMemberViewModel.swift b/QuickLocation/Section/Group/RemoveMember/RemoveMemberViewModel.swift index bc912175..f5807a41 100644 --- a/QuickLocation/Section/Group/RemoveMember/RemoveMemberViewModel.swift +++ b/QuickLocation/Section/Group/RemoveMember/RemoveMemberViewModel.swift @@ -77,7 +77,8 @@ class RemoveMemberViewModel { items = list } else { items = list.filter { - $0.nick_name.localizedCaseInsensitiveContains(searchKeyword) + $0.showName.localizedCaseInsensitiveContains(searchKeyword) + || $0.nick_name.localizedCaseInsensitiveContains(searchKeyword) } } sectionedItems.onNext(items.mapSection()) diff --git a/QuickLocation/Section/Group/ReviewMemberList/ReviewMemberListView.swift b/QuickLocation/Section/Group/ReviewMemberList/ReviewMemberListView.swift index c02589e9..b05fb62e 100644 --- a/QuickLocation/Section/Group/ReviewMemberList/ReviewMemberListView.swift +++ b/QuickLocation/Section/Group/ReviewMemberList/ReviewMemberListView.swift @@ -104,8 +104,8 @@ class ReviewMemberCell: UITableViewCell { var disposeBag = DisposeBag() func configure(_ model: GroupMemberModel) { - avaterImgView.image = model.userIcon - nameLab.text = model.nick_name + avaterImgView.setHeadPic(model.displayHeadPic) + nameLab.text = model.showName } override init(style: CellStyle, reuseIdentifier: String?) { diff --git a/QuickLocation/Section/Home/Bubble/BubbleHeroView.swift b/QuickLocation/Section/Home/Bubble/BubbleHeroView.swift index b0a1e84e..f2285804 100644 --- a/QuickLocation/Section/Home/Bubble/BubbleHeroView.swift +++ b/QuickLocation/Section/Home/Bubble/BubbleHeroView.swift @@ -159,8 +159,7 @@ final class BubbleHeroView: UIView { } func reloadAvatar() { - let icon = AppContextManager.shared.avaterIcon - avatarView.image = icon.size.width > 0 ? icon : UIImage(named: "Common/default_avatar") + avatarView.setHeadPic(AppContextManager.shared.head_pic) } private func setupUI() { diff --git a/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift b/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift index 3839c722..11cb28b0 100644 --- a/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift +++ b/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift @@ -168,8 +168,7 @@ final class CreateBubbleSetupView: UIView { previewAvatar.layer.cornerRadius = 20 previewAvatar.layer.borderWidth = 2 previewAvatar.layer.borderColor = UIColor.white.cgColor - let avatar = AppContextManager.shared.avaterIcon - previewAvatar.image = avatar.size.width > 0 ? avatar : UIImage(named: "Common/default_avatar") + previewAvatar.setHeadPic(AppContextManager.shared.head_pic) sheetPanel.addSubview(previewAvatar) messageView.layoutChain @@ -277,6 +276,7 @@ private final class BubbleHourPickerView: UIView, UICollectionViewDataSource, UI private let itemWidth: CGFloat = 52 private let itemSpacing: CGFloat = 12 private let accent = UIColor(hexStr: "#FF5CB8") + private let selectionFeedbackGenerator = UISelectionFeedbackGenerator() private var currentHour = 1 private var didInitialScroll = false @@ -402,6 +402,8 @@ private final class BubbleHourPickerView: UIView, UICollectionViewDataSource, UI guard hour != currentHour else { return } currentHour = hour capsuleLab.text = "\(hour)小时" + selectionFeedbackGenerator.selectionChanged() + selectionFeedbackGenerator.prepare() onHourChanged?(hour) } @@ -435,6 +437,10 @@ private final class BubbleHourPickerView: UIView, UICollectionViewDataSource, UI commitCenteredHour() } + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + selectionFeedbackGenerator.prepare() + } + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { commitCenteredHour() } diff --git a/QuickLocation/Section/Home/GroupMemberView.swift b/QuickLocation/Section/Home/GroupMemberView.swift index 0e82fd0c..e4be1bbf 100644 --- a/QuickLocation/Section/Home/GroupMemberView.swift +++ b/QuickLocation/Section/Home/GroupMemberView.swift @@ -156,9 +156,9 @@ class GroupMemberCell: UITableViewCell { func configure(model: GroupMemberModel, isCurrentUser: Bool, isOwn: Bool) { ownView.isHidden = !isOwn - avaterImgView.image = model.userIcon + avaterImgView.setHeadPic(model.displayHeadPic) vipIcon.image = model.vipIcon - nameLab.text = model.nick_name + nameLab.text = model.showName // 位置 locationLab.text = "在 " + model.lastLocation diff --git a/QuickLocation/Section/Home/GroupMemberView2.swift b/QuickLocation/Section/Home/GroupMemberView2.swift index 2323ad56..a707fad8 100644 --- a/QuickLocation/Section/Home/GroupMemberView2.swift +++ b/QuickLocation/Section/Home/GroupMemberView2.swift @@ -20,6 +20,7 @@ class GroupMemberView2: UIView { private var currentMemberModel: GroupMemberModel? private var emojiEchoByUserId: [String: String] = [:] private var messageEchoByUserId: [String: String] = [:] + var onCoupleMemberTap: ((GroupMemberModel) -> Void)? private static let batteryNormalColor = UIColor(hexStr: "#47DD00") private static let batteryLowColor = UIColor(hexStr: "#F85A5D") @@ -38,7 +39,7 @@ class GroupMemberView2: UIView { phoneUsage: PhoneUsageTodayModel? ) { currentMemberModel = model - memberNameLab.text = model.nick_name + memberNameLab.text = model.showName ownView.isHidden = !isOwner relationIconView.configure(relationIdx: model.extra.relation_idx) updateMoodBadge(model.mood) @@ -237,24 +238,8 @@ class GroupMemberView2: UIView { private static func screenTimeSeconds(from value: String?) -> Int? { guard let value else { return nil } let text = value.trimmed - guard !text.isEmpty else { return nil } - - let hours = firstInteger(in: text, before: "小时") ?? 0 - let minutes: Int - if text.contains("分钟") { - minutes = firstInteger(in: text, before: "分钟") ?? 0 - } else { - minutes = firstInteger(in: text, before: "分") ?? 0 - } - guard text.contains("小时") || text.contains("分") else { return nil } - return max(0, hours * 3600 + minutes * 60) - } - - private static func firstInteger(in text: String, before suffix: String) -> Int? { - guard let range = text.range(of: suffix) else { return nil } - let prefix = text[.. String { + guard let placemark else { return "" } + let province = placemark.administrativeArea?.trimmed ?? "" + let city = placemark.locality?.trimmed + ?? placemark.subAdministrativeArea?.trimmed + ?? "" + let district = placemark.subLocality?.trimmed ?? "" + let detail = placemark.name?.trimmed + ?? placemark.thoroughfare?.trimmed + ?? "" + let municipalityNames: Set = ["北京", "上海", "天津", "重庆"] + + var components: [String] = [] + if !province.isEmpty { + components.append(province) + } + let provinceName = province.replacingOccurrences(of: "市", with: "") + let cityName = city.replacingOccurrences(of: "市", with: "") + let isMunicipality = municipalityNames.contains(provinceName) && provinceName == cityName + if !city.isEmpty, !isMunicipality, !components.contains(city) { + components.append(city) + } + if !district.isEmpty, !components.contains(district) { + components.append(district) + } + + var remainingDetail = detail + for component in components where remainingDetail.hasPrefix(component) { + remainingDetail.removeFirst(component.count) + } + if !remainingDetail.isEmpty, !components.contains(remainingDetail) { + components.append(remainingDetail) + } + return components.joined() + } + private func publishLocation(coord: CLLocationCoordinate2D, address: String, loc: CLLocation) { let work = { MQTTService.shared.reportLocation( @@ -187,17 +217,20 @@ class HomeViewController: BaseViewController { // MARK: - Actions private func reactiveAction() { + rootView.groupMemberView.onCoupleMemberTap = { [weak self] member in + self?.selectMember(userId: member.user_id, centerMap: true, dismissPanel: true) + } + rootView.onUnlockRequestTap = { [weak self] in guard let self else { return } - let requests = self.makeMockUnlockRequests() + let requests = self.makeUnlockRequestDisplayItems() + guard !requests.isEmpty else { return } UnlockRequestPopView.show( requests: requests, - onUnlock: { _ in - // 预留解锁回调,后续接入 AppRestrict / API。 + onUnlock: { [weak self] item, done in + self?.unlockMember(item, done: done) }, - onReject: { _ in - // 预留拒绝回调,后续接入 API。 - } + onReject: { _ in } ) } @@ -317,7 +350,6 @@ class HomeViewController: BaseViewController { Defaults[\.loginToken] = model.token AppContextManager.shared.systemConfig = model.config // rootView.searchLottieView.isHidden = model.config?.isIntercept == true - self.getUserIMToken() self.requestUserInfo { [weak self] in self?.requestGroupInfo() } @@ -395,6 +427,7 @@ class HomeViewController: BaseViewController { guard let self = self else { return } let items = sections.flatMap(\.items) guard !items.isEmpty else { + self.refreshCoupleMemberList([]) self.rootView.setSOSGradientActive(false) return } @@ -406,6 +439,7 @@ class HomeViewController: BaseViewController { self.selectedMemberId = items[0].user_id } } + self.refreshCoupleMemberList(items) self.refreshSelectedMemberInfoIfNeeded(userId: self.selectedMemberId) self.updateSelectedMemberSOSGradient() }) @@ -440,35 +474,33 @@ class HomeViewController: BaseViewController { // MARK: - API - /// 获取用户IM Token - func getUserIMToken() { - DLToast.showLoading() - UserService.imToken().subscribe(onNext: { response in - guard let data = response.data, let token = data["token"] as? String else { return } - AppContextManager.shared.imToken = token - GroupIMService.shared.login { _ in - DLToast.dismiss() - } - }).disposed(by: disposeBag) + private func queryUnlockRequests() { + UserService.phoneUnlockRequests(os: "ios") + .subscribe(onNext: { [weak self] response in + guard let self else { return } + let requested = response.list.filter { $0.requestUnlock == 1 } + self.unlockRequestItems = requested.isEmpty ? response.list : requested + self.rootView.unlockRequestBtn.isHidden = self.unlockRequestItems.isEmpty + }, onError: { [weak self] _ in + self?.rootView.unlockRequestBtn.isHidden = self?.unlockRequestItems.isEmpty ?? true + }) + .disposed(by: disposeBag) } - private func makeMockUnlockRequests() -> [UnlockRequestDisplayItem] { - var candidates = viewModel.memberList - guard !candidates.isEmpty else { return [] } - - if let selectedIndex = candidates.firstIndex(where: { $0.user_id == selectedMemberId }) { - let selectedMember = candidates.remove(at: selectedIndex) - candidates.insert(selectedMember, at: 0) - } - - while candidates.count < 3 { - candidates.append(contentsOf: candidates) - } - - return Array(candidates.prefix(3)).enumerated().map { index, member in - UnlockRequestDisplayItem( + private func makeUnlockRequestDisplayItems() -> [UnlockRequestDisplayItem] { + unlockRequestItems.map { item in + let member = viewModel.memberList.first { member in + member.user_id == item.targetUserId + } + return UnlockRequestDisplayItem( member: member, - lockStartTime: mockUnlockStartTimes[index] + fallbackName: item.nickName, + headPic: item.headPic, + lockStartTime: item.lockStartDate, + os: item.os, + groupKey: item.groupKey, + userId: item.targetUserId, + tokens: item.tokens ) } } @@ -502,6 +534,7 @@ class HomeViewController: BaseViewController { messageUUID: message.message_uuid, senderName: message.from_user?.displayName ?? "圈子成员", avatar: message.from_user?.avatarImage, + headPic: message.from_user?.displayHeadPic ?? "", image: localTemplateImage, imageURL: localTemplateImage == nil ? message.backgroundURL : nil, message: message.captionText, @@ -787,23 +820,21 @@ class HomeViewController: BaseViewController { } let currentUserId = AppContextManager.shared.userId - var lastUpdateTime: Int64 = 0 - if let currentModel = list.first(where: { $0.user_id == AppContextManager.shared.userId }) { - lastUpdateTime = currentModel.last_active_time - } + let currentModel = list.first(where: { $0.user_id == currentUserId }) + let lastUpdateTime = currentModel?.last_active_time ?? 0 - // 当前用户始终由 GPS 定位,单独注入 + // 当前用户始终由 GPS 定位,单独注入;头像与成员列表同一份数据 let me = CircleMember( id: "current", - name: AppContextManager.shared.name, - avatar: AppContextManager.shared.account?.head_pic ?? "1", + name: currentModel?.showName ?? AppContextManager.shared.name, + avatar: currentModel?.displayHeadPic ?? AppContextManager.shared.head_pic, isOnline: true, - isOwner: false, + isOwner: currentModel.map { isGroupOwner($0.user_id) } ?? false, coordinate: kCLLocationCoordinate2DInvalid, address: "", heading: 0, lastUpdateTime: lastUpdateTime, - battery: "" + battery: currentModel?.battery ?? "" ) // 其他成员来自当前圈子的成员接口,过滤掉当前用户。 @@ -829,6 +860,7 @@ class HomeViewController: BaseViewController { let newMembers = others + [me] members = newMembers currentUserMember = me + refreshCurrentUserAnnotationAvatar(me) // 从 members 中过滤出在线成员用于地图标注(当前用户坐标无效,由 GPS 回调单独添加) let onlineMembers = newMembers.filter { $0.isOnline && !$0.isCurrentUser && !isMemberInBubble($0.id) } @@ -877,6 +909,30 @@ class HomeViewController: BaseViewController { #endif } + #if !targetEnvironment(simulator) + private func refreshCurrentUserAnnotationAvatar(_ me: CircleMember) { + guard let ann = currentUserAnnotation else { return } + let updated = CircleMember( + id: "current", + name: me.name, + avatar: me.avatar, + isOnline: true, + isOwner: me.isOwner, + coordinate: ann.coordinate, + address: me.address, + heading: ann.member.heading, + lastUpdateTime: me.lastUpdateTime, + battery: me.battery + ) + ann.member = updated + currentUserMember = updated + if let view = rootView.mapView.view(for: ann) as? MemberAnnotationView { + view.configure(with: updated) + view.updateHeading(currentHeading) + } + } + #endif + /// 若更新的是当前选中成员,刷新信息卡(位置 / 在线 / 距离等) private func refreshSelectedMemberInfoIfNeeded(userId: String) { guard userId == selectedMemberId, @@ -1002,6 +1058,7 @@ class HomeViewController: BaseViewController { refreshSelectedMemberInfoIfNeeded(userId: listUserId) requestSelectedMemberPhoneUsage() memberCV.reloadData() + refreshCoupleMemberList(viewModel.memberList) if let idx = viewModel.memberList.firstIndex(where: { $0.user_id == listUserId }) { let indexPath = IndexPath(item: idx, section: 0) @@ -1029,6 +1086,17 @@ class HomeViewController: BaseViewController { #endif } + private func refreshCoupleMemberList(_ members: [GroupMemberModel]) { + let showCoupleMembers = viewModel.isCoupleGroup && members.count >= 2 + let memberView = rootView.groupMemberView + memberView.setCoupleMode(showCoupleMembers) + memberView.configureCoupleMembers( + showCoupleMembers ? members : [], + selectedId: selectedMemberId, + currentUserId: AppContextManager.shared.userId + ) + } + private func startFollowingCurrentUser() { isAutoFollowingUser = true #if !targetEnvironment(simulator) @@ -1088,11 +1156,22 @@ extension HomeViewController { private func debounceGroupRefresh() { groupRefreshWorkItem?.cancel() let work = DispatchWorkItem { [weak self] in - self?.requestGroupInfo() + self?.requestGroupMembersFromCache() } groupRefreshWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work) } + + private func requestGroupMembersFromCache() { + let groupKey = AppContextManager.shared.defaultGroupKey + guard !groupKey.isEmpty else { + requestGroupInfo() + return + } + let requestID = UUID() + groupInfoRequestID = requestID + requestGroupMembers(groupKey: groupKey, requestID: requestID) + } /// 切换圈子时刷新 MQTT 订阅 private func refreshMQTTSubscriptions(_ members: [GroupMemberModel]) { // let currentId = AppContextManager.shared.userId @@ -1109,6 +1188,66 @@ extension HomeViewController { subscribedMemberIds = newIds } + private func querySelfLocked() { + let userId = AppContextManager.shared.userId.trimmed + let lockStateRevision = AppUnlockCoordinator.shared.currentLockStateRevision() + UserService.phoneLocked(os: "ios") + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] response in + guard let locks = AppUnlockCoordinator.shared.locksForRestoration( + response.model?.locks ?? [], + startedAt: lockStateRevision, + userId: userId + ) else { return } + guard !locks.isEmpty else { return } + self?.applyIncomingLocks(locks) + }) + .disposed(by: disposeBag) + } + + private func unlockMember(_ item: UnlockRequestDisplayItem, done: @escaping (Bool) -> Void) { + let os = item.os.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "ios" : item.os + guard !item.groupKey.isEmpty, !item.userId.isEmpty, !item.tokens.isEmpty else { + DLToast.show(text: "解锁数据缺失") + done(false) + return + } + DLToast.showLoading() + UserService.phoneUnlock( + os: os, + groupKey: item.groupKey, + userId: item.userId, + tokens: item.tokens + ) + .subscribe(onNext: { [weak self] _ in + DLToast.show(text: "已解锁") + if item.userId == AppContextManager.shared.userId { + AppUnlockCoordinator.shared.applyUnlock(tokens: item.tokens) + } + self?.queryUnlockRequests() + done(true) + }, onError: { error in + DLToast.show(text: error.gatewayMessage ?? "解锁失败") + done(false) + }) + .disposed(by: disposeBag) + } + + private func applyIncomingLocks(_ locks: [PhoneLockRecord]) { + PhoneLockSession.currentLocks = locks + guard let first = locks.first else { return } + let tokens = locks.flatMap(\.tokens) + if #available(iOS 16.0, *) { + AppRestrictManager.shared.applyRemoteLock( + tokens: tokens, + iconIndex: first.iconIndex, + message: first.message, + groupName: first.displayGroupName + ) + } + LockedAppPopView.show(first) + } + // MARK: - 处理 MQTT 消息(按 type 分发) private func handleMemberLocation(topic: String, payload: String?) { print("📩 收到消息 -> 主题:\(topic),内容:\(payload ?? "Unkown")") @@ -1168,6 +1307,18 @@ extension HomeViewController { updateOnlineCount() refreshSelectedMemberInfoIfNeeded(userId: userId) + case "lockApp": + guard let body = msg.data?.lock_app else { break } + let record = PhoneLockRecord(mqtt: body) + let selfId = AppContextManager.shared.userId + let isSelf = (!record.userId.isEmpty && record.userId == selfId) + || userId == selfId + DispatchQueue.main.async { [weak self] in + NotificationCenter.default.post(name: .lockDistractAppsDidChange, object: nil) + guard isSelf else { return } + AppUnlockCoordinator.shared.registerIncomingLock(tokens: record.tokens) + self?.applyIncomingLocks([record]) + } case "emote": // 快捷消息、表情 guard let userId = msg.data?.user_id, userId == AppContextManager.shared.userId, // 只接收发给我的 let index = msg.data?.index, index > 9, @@ -1541,8 +1692,8 @@ extension HomeViewController { guard let model = viewModel.memberList.first(where: { $0.user_id == userId }) else { return nil } let created = CircleMember( id: model.user_id, - name: model.nick_name, - avatar: model.head_pic, + name: model.showName, + avatar: model.displayHeadPic, isOnline: true, isOwner: viewModel.isGroupOwn(id: model.user_id), coordinate: coordinate, diff --git a/QuickLocation/Section/Home/HomeViewModel.swift b/QuickLocation/Section/Home/HomeViewModel.swift index 21fa65ad..9bd761fd 100644 --- a/QuickLocation/Section/Home/HomeViewModel.swift +++ b/QuickLocation/Section/Home/HomeViewModel.swift @@ -49,6 +49,15 @@ class HomeViewModel { return model.groups.first(where: { $0.group_key == model.default_group_key })?.name ?? "" } } + + var currentGroupInfo: GroupInfoModel? { + guard let model = groupModel else { return nil } + return model.groups.first(where: { $0.group_key == model.default_group_key }) + } + + var isCoupleGroup: Bool { + currentGroupInfo?.isCoupleGroup == true + } // 圈子成员数 var memberCount: Int { @@ -72,7 +81,7 @@ class HomeViewModel { } func getUserNickName(id: String) -> String { - memberList.first { $0.user_id == id }?.nick_name ?? "" + memberList.first { $0.user_id == id }?.showName ?? "" } private(set) var memberList: [GroupMemberModel] = [] diff --git a/QuickLocation/Section/Home/InteractionView.swift b/QuickLocation/Section/Home/InteractionView.swift index 098ad420..e9157017 100644 --- a/QuickLocation/Section/Home/InteractionView.swift +++ b/QuickLocation/Section/Home/InteractionView.swift @@ -21,7 +21,7 @@ class InteractionView: UIView { func configure(member: CircleMember) { self.currentMember = member - avaterImgView.image = UIImage(named: "UserIcon/\(member.avatar)") + avaterImgView.setHeadPic(member.avatar) nameLab.text = member.name locationLab.text = member.address diff --git a/QuickLocation/Section/Home/LockedAppPopView.swift b/QuickLocation/Section/Home/LockedAppPopView.swift new file mode 100644 index 00000000..0e4687aa --- /dev/null +++ b/QuickLocation/Section/Home/LockedAppPopView.swift @@ -0,0 +1,390 @@ +// +// LockedAppPopView.swift +// QuickLocation +// + +import UIKit +import RxSwift + +final class LockedAppPopView: UIView { + + private static let shared = LockedAppPopView(frame: .zero) + private static var hostWindow: UIWindow? + private static var showRetryCount = 0 + + private var record: PhoneLockRecord? + private var timer: Timer? + private var disposeBag = DisposeBag() + private let digitLabels = (0..<4).map { _ in LockedAppTimeDigitLabel() } + + static func show(_ record: PhoneLockRecord) { + let popup = LockedAppPopView.shared + popup.stopTimer() + popup.configure(record) + + guard let window = attachedWindow() else { + guard showRetryCount < 10 else { return } + showRetryCount += 1 + DispatchQueue.main.async { + show(record) + } + return + } + showRetryCount = 0 + + popup.frame = window.bounds + popup.autoresizingMask = [.flexibleWidth, .flexibleHeight] + if popup.superview !== window { + popup.removeFromSuperview() + window.addSubview(popup) + } + window.isHidden = false + popup.layoutIfNeeded() + popup.startTimer() + popup.startStruggleAnimation() + + popup.overlayView.alpha = 0 + popup.cardView.transform = CGAffineTransform(scaleX: 0.92, y: 0.92) + popup.cardView.alpha = 0 + UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut]) { + popup.overlayView.alpha = 1 + popup.cardView.alpha = 1 + popup.cardView.transform = .identity + } + } + + static func dismiss() { + let popup = LockedAppPopView.shared + popup.stopTimer() + popup.stopStruggleAnimation() + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) { + popup.overlayView.alpha = 0 + popup.cardView.alpha = 0 + popup.cardView.transform = CGAffineTransform(scaleX: 0.94, y: 0.94) + } completion: { _ in + popup.removeFromSuperview() + popup.cardView.transform = .identity + popup.record = nil + hostWindow?.isHidden = true + hostWindow = nil + } + } + + private static func attachedWindow() -> UIWindow? { + if let hostWindow { + hostWindow.frame = hostWindow.windowScene?.coordinateSpace.bounds ?? UIScreen.main.bounds + return hostWindow + } + guard let scene = activeWindowScene() else { return nil } + let window = UIWindow(windowScene: scene) + window.windowLevel = .alert + 1 + window.backgroundColor = .clear + window.isHidden = false + hostWindow = window + return window + } + + private static func activeWindowScene() -> UIWindowScene? { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + return scenes.first { $0.activationState == .foregroundActive } + ?? scenes.first { $0.activationState == .foregroundInactive } + ?? scenes.first + } + + private let overlayView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.62) + return view + }() + + private let cardView: LockedAppGradientView = { + let view = LockedAppGradientView() + view.layer.cornerRadius = 40 + view.clipsToBounds = true + return view + }() + + private let titleLab: UILabel = { + let lab = UILabel() + lab.text = "已锁" + lab.font = FontManager.boboBold(32) + lab.textColor = UIColor(hexStr: "#293445") + lab.textAlignment = .center + return lab + }() + + private let avatarLockView: UIView = { + let view = UIView() + view.clipsToBounds = false + return view + }() + + private let wallpaperView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.layer.cornerRadius = 28 + view.layer.borderWidth = 4 + view.layer.borderColor = UIColor.white.cgColor + return view + }() + + private let overlayIcon: UIImageView = { + let view = UIImageView(image: UIImage(named: "LockDistract/app_locked_overlay")) + view.contentMode = .scaleAspectFit + return view + }() + + private let requestBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("请求TA解锁", for: .normal) + btn.setTitleColor(.white, for: .normal) + btn.titleLabel?.font = FontManager.boboBold(18) + btn.backgroundColor = UIColor(hexStr: "#293445") + btn.layer.cornerRadius = 20 + return btn + }() + + private let ignoreBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("无所谓", for: .normal) + btn.setTitleColor(UIColor(hexStr: "#A8AFBA"), for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + return btn + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + addSubview(overlayView) + addSubview(cardView) + overlayView.layoutChain.edges() + cardView.layoutChain + .centerX() + .centerY() + .width(315) + .height(430) + + cardView.addSubview(titleLab) + titleLab.layoutChain.top(28).centerX() + + let colon = UILabel() + colon.text = ":" + colon.font = FontManager.boboBold(22) + colon.textColor = UIColor(hexStr: "#293445") + colon.textAlignment = .center + let timeStack = UIStackView(arrangedSubviews: [ + digitLabels[0], digitLabels[1], colon, digitLabels[2], digitLabels[3] + ]) + timeStack.axis = .horizontal + timeStack.alignment = .center + timeStack.spacing = 4 + cardView.addSubview(timeStack) + timeStack.layoutChain + .topToBottomOfView(titleLab, offset: 16) + .centerX() + .height(36) + digitLabels.forEach { label in + label.layoutChain.width(28).height(36) + } + + cardView.addSubview(avatarLockView) + avatarLockView.addSubview(wallpaperView) + avatarLockView.addSubview(overlayIcon) + avatarLockView.layoutChain + .topToBottomOfView(timeStack, offset: 22) + .centerX() + .width(168) + .height(168) + wallpaperView.layoutChain + .centerX() + .centerY() + .width(148) + .height(148) + overlayIcon.layoutChain.edges() + + cardView.addSubview(requestBtn) + requestBtn.layoutChain + .topToBottomOfView(avatarLockView, offset: 28) + .edgesHorzontal(20) + .height(48) + + cardView.addSubview(ignoreBtn) + ignoreBtn.layoutChain + .topToBottomOfView(requestBtn, offset: 10) + .centerX() + + requestBtn.addTarget(self, action: #selector(tapRequest), for: .touchUpInside) + ignoreBtn.addTarget(self, action: #selector(tapIgnore), for: .touchUpInside) + } + + private func configure(_ record: PhoneLockRecord) { + self.record = record + wallpaperView.setHeadPic(AppContextManager.shared.head_pic) + refreshElapsedTime() + } + + private func startTimer() { + refreshElapsedTime() + let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in + self?.refreshElapsedTime() + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + private func startStruggleAnimation() { + stopStruggleAnimation() + guard !UIAccessibility.isReduceMotionEnabled else { return } + + let cycle: CFTimeInterval = 1.25 + let keyTimes: [NSNumber] = [0, 0.08, 0.16, 0.26, 0.36, 0.44, 0.52, 1] + let timing = CAMediaTimingFunction(name: .easeInEaseOut) + + let avatarRotate = CAKeyframeAnimation(keyPath: "transform.rotation.z") + avatarRotate.values = [0, 0.12, -0.14, 0.11, -0.09, 0.05, 0, 0] + avatarRotate.keyTimes = keyTimes + avatarRotate.duration = cycle + avatarRotate.repeatCount = .infinity + avatarRotate.timingFunction = timing + + let avatarMoveX = CAKeyframeAnimation(keyPath: "transform.translation.x") + avatarMoveX.values = [0, 5, -5, 4, -3, 2, 0, 0] + avatarMoveX.keyTimes = keyTimes + avatarMoveX.duration = cycle + avatarMoveX.repeatCount = .infinity + avatarMoveX.timingFunction = timing + + let avatarMoveY = CAKeyframeAnimation(keyPath: "transform.translation.y") + avatarMoveY.values = [0, -3, 2, -2, 3, -1, 0, 0] + avatarMoveY.keyTimes = keyTimes + avatarMoveY.duration = cycle + avatarMoveY.repeatCount = .infinity + avatarMoveY.timingFunction = timing + + wallpaperView.layer.add(avatarRotate, forKey: "struggle.rotate") + wallpaperView.layer.add(avatarMoveX, forKey: "struggle.moveX") + wallpaperView.layer.add(avatarMoveY, forKey: "struggle.moveY") + + let chainRotate = CAKeyframeAnimation(keyPath: "transform.rotation.z") + chainRotate.values = [0, -0.05, 0.05, -0.04, 0.035, -0.02, 0, 0] + chainRotate.keyTimes = keyTimes + chainRotate.duration = cycle + chainRotate.repeatCount = .infinity + chainRotate.timingFunction = timing + + let chainScale = CAKeyframeAnimation(keyPath: "transform.scale") + chainScale.values = [1, 1.03, 1.01, 1.045, 1.02, 1.01, 1, 1] + chainScale.keyTimes = keyTimes + chainScale.duration = cycle + chainScale.repeatCount = .infinity + chainScale.timingFunction = timing + + overlayIcon.layer.add(chainRotate, forKey: "struggle.rotate") + overlayIcon.layer.add(chainScale, forKey: "struggle.scale") + } + + private func stopStruggleAnimation() { + wallpaperView.layer.removeAnimation(forKey: "struggle.rotate") + wallpaperView.layer.removeAnimation(forKey: "struggle.moveX") + wallpaperView.layer.removeAnimation(forKey: "struggle.moveY") + overlayIcon.layer.removeAnimation(forKey: "struggle.rotate") + overlayIcon.layer.removeAnimation(forKey: "struggle.scale") + wallpaperView.transform = .identity + overlayIcon.transform = .identity + } + + private func refreshElapsedTime() { + guard let record else { return } + let elapsed = max(0, Int(Date().timeIntervalSince(record.lockStartDate))) + let capped = min(elapsed, 99 * 60 + 59) + let minutes = capped / 60 + let seconds = capped % 60 + let digits = String(format: "%02d%02d", minutes, seconds) + for (index, label) in digitLabels.enumerated() { + let stringIndex = digits.index(digits.startIndex, offsetBy: index) + label.text = String(digits[stringIndex]) + } + } + + @objc private func tapRequest() { + let locks = PhoneLockSession.currentLocks + let source = record ?? locks.first + let groupKey = source?.groupKey ?? "" + let os = { + let value = source?.os.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return value.isEmpty ? "ios" : value + }() + let tokens = locks.flatMap(\.tokens) + guard !groupKey.isEmpty, !tokens.isEmpty else { + DLToast.show(text: "锁定数据缺失") + return + } + DLToast.showLoading() + disposeBag = DisposeBag() + UserService.requestPhoneUnlock(os: os, groupKey: groupKey, tokens: tokens) + .subscribe(onNext: { _ in + DLToast.show(text: "已向圈主发送解锁请求") + Self.dismiss() + }, onError: { error in + DLToast.show(text: error.gatewayMessage ?? "发送失败") + }) + .disposed(by: disposeBag) + } + + @objc private func tapIgnore() { + Self.dismiss() + } +} + +private final class LockedAppTimeDigitLabel: UILabel { + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#293445") + textColor = .white + font = FontManager.boboBold(20) + textAlignment = .center + layer.cornerRadius = 8 + clipsToBounds = true + text = "0" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class LockedAppGradientView: UIView { + override class var layerClass: AnyClass { + CAGradientLayer.self + } + + override init(frame: CGRect) { + super.init(frame: frame) + guard let layer = layer as? CAGradientLayer else { return } + layer.colors = [ + UIColor(hexStr: "#91DEFA").cgColor, + UIColor(hexStr: "#D9F4FD").cgColor, + UIColor.white.cgColor + ] + layer.locations = [0, 0.58, 1] + layer.startPoint = CGPoint(x: 0.5, y: 0) + layer.endPoint = CGPoint(x: 0.5, y: 1) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/QuickLocation/Section/Home/ReceiveMessagePopView.swift b/QuickLocation/Section/Home/ReceiveMessagePopView.swift index 7ef8d3a3..25c7af15 100644 --- a/QuickLocation/Section/Home/ReceiveMessagePopView.swift +++ b/QuickLocation/Section/Home/ReceiveMessagePopView.swift @@ -16,6 +16,7 @@ struct ReceiveMessageDisplayItem { let messageUUID: String let senderName: String let avatar: UIImage? + let headPic: String let image: UIImage? let imageURL: URL? let message: String @@ -29,6 +30,7 @@ struct ReceiveMessageDisplayItem { messageUUID: String = "", senderName: String, avatar: UIImage?, + headPic: String = "", image: UIImage?, imageURL: URL? = nil, message: String, @@ -41,6 +43,7 @@ struct ReceiveMessageDisplayItem { self.messageUUID = messageUUID self.senderName = senderName self.avatar = avatar + self.headPic = headPic self.image = image self.imageURL = imageURL self.message = message @@ -369,7 +372,7 @@ final class ReceiveMessagePopView: UIView { stopVoicePlayback(reset: true) nameLabel.text = item.senderName.isEmpty ? "圈子成员" : item.senderName applyRelation(item.relationIdx) - avatarView.image = item.avatar ?? UIImage(named: "UserIcon/1") + avatarView.setHeadPic(item.headPic) messageImageView.kf.cancelDownloadTask() if let imageURL = item.imageURL { messageImageView.kf.setImage(with: imageURL, placeholder: item.image) diff --git a/QuickLocation/Section/Home/SOS/SOSReceiveAlertView.swift b/QuickLocation/Section/Home/SOS/SOSReceiveAlertView.swift index 37027f08..e42c3b55 100644 --- a/QuickLocation/Section/Home/SOS/SOSReceiveAlertView.swift +++ b/QuickLocation/Section/Home/SOS/SOSReceiveAlertView.swift @@ -159,11 +159,8 @@ final class SOSReceiveAlertView: UIView { secondaryAvatarViews.removeAll() extraBadge.isHidden = true - let image = member.userIcon.size == .zero - ? UIImage(named: "Common/default_avatar") - : member.userIcon - primaryAvatarView.image = image - nameLabel.text = member.nick_name.isEmpty ? "圈子成员" : member.nick_name + primaryAvatarView.setHeadPic(member.displayHeadPic) + nameLabel.text = member.showName.isEmpty ? "圈子成员" : member.showName isHidden = false setNeedsLayout() layoutIfNeeded() @@ -270,9 +267,7 @@ final class SOSReceiveAlertView: UIView { private func makeSecondaryAvatar(_ member: GroupMemberModel) -> UIImageView { let imageView = UIImageView() - imageView.image = member.userIcon.size == .zero - ? UIImage(named: "Common/default_avatar") - : member.userIcon + imageView.setHeadPic(member.displayHeadPic) imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.layer.borderWidth = 4 diff --git a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift index 533e8428..72f891b4 100644 --- a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift +++ b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift @@ -9,6 +9,7 @@ import RxCocoa import AMapNaviKit import SwiftDate import ObjectMapper +import CoreLocation final class TodayTrackDetailVC: BaseViewController { @@ -16,7 +17,21 @@ final class TodayTrackDetailVC: BaseViewController { private let viewModel: TodayTrackDetailViewModel private var routeOverlays: [MAPolyline] = [] private var panStartHeight: CGFloat = 0 - private var stayList: [StayPoint] = [] + private var tripList: [ScheduleRecordModel] = [] + private var didScrollDatesToEnd = false + private var lastDateCVWidth: CGFloat = 0 + private var suppressMapTapHide = false + + private var playbackPath: [(coordinate: CLLocationCoordinate2D, distance: Double)] = [] + private var playbackTotalDistance: Double = 0 + private var playbackAnnotation: HistoryTrackPlaybackAnnotation? + private var displayLink: CADisplayLink? + private var playbackProgress: Double = 0 + private var playbackLastTick: Date? + private var playbackDuration: TimeInterval = 30 + private let minPlaybackDuration: TimeInterval = 10 + private let maxPlaybackDuration: TimeInterval = 180 + private let playbackReplaySpeed: CLLocationDistance = 80 init(members: [GroupMemberModel], selectedUserId: String) { self.viewModel = TodayTrackDetailViewModel(members: members, selectedUserId: selectedUserId) @@ -40,6 +55,10 @@ final class TodayTrackDetailVC: BaseViewController { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + @MainActor deinit { + displayLink?.invalidate() + } + override func loadView() { rootView = TodayTrackDetailView(frame: UIScreen.main.bounds) view = rootView @@ -56,13 +75,33 @@ final class TodayTrackDetailVC: BaseViewController { rootView.timelineTV.delegate = self bindActions() bindSheetPan() + setupPlaybackControls() rootView.memberCV.reloadData() rootView.dateCV.reloadData() + scrollToSelectedMember(animated: false) + DispatchQueue.main.async { [weak self] in + self?.rootView.scrollDatesToEnd(animated: false) + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let width = rootView.dateCV.bounds.width + guard width > 0, abs(width - lastDateCVWidth) > 0.5 else { return } + lastDateCVWidth = width + rootView.dateCV.collectionViewLayout.invalidateLayout() + rootView.dateCV.reloadData() + if !didScrollDatesToEnd { + didScrollDatesToEnd = true + rootView.scrollDatesToEnd(animated: false) + } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) if isMovingFromParent || isBeingDismissed { + DLToast.dismiss() + stopPlayback(resetProgress: false) rootView.cleanupMap() } } @@ -74,10 +113,22 @@ final class TodayTrackDetailVC: BaseViewController { }) .disposed(by: disposeBag) - viewModel.trajectoryPoints + rootView.datePrevBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.rootView.scrollDates(direction: -1) + }) + .disposed(by: disposeBag) + + rootView.dateNextBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.rootView.scrollDates(direction: 1) + }) + .disposed(by: disposeBag) + + viewModel.displayedTrips .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] points in - self?.drawRoute(points) + .subscribe(onNext: { [weak self] trips in + self?.drawRoute(trips) }) .disposed(by: disposeBag) @@ -88,10 +139,10 @@ final class TodayTrackDetailVC: BaseViewController { }) .disposed(by: disposeBag) - viewModel.stayPoints + viewModel.trips .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] stays in - self?.stayList = stays + .subscribe(onNext: { [weak self] trips in + self?.tripList = trips self?.rootView.timelineTV.reloadData() }) .disposed(by: disposeBag) @@ -100,6 +151,7 @@ final class TodayTrackDetailVC: BaseViewController { .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] _ in self?.rootView.memberCV.reloadData() + self?.scrollToSelectedMember(animated: true) }) .disposed(by: disposeBag) @@ -109,6 +161,20 @@ final class TodayTrackDetailVC: BaseViewController { self?.rootView.dateCV.reloadData() }) .disposed(by: disposeBag) + + viewModel.loading + .observe(on: MainScheduler.instance) + .distinctUntilChanged() + .subscribe(onNext: { loading in + if loading { + DLToast.showLoading() + } else { + DLToast.dismiss() + } + }) + .disposed(by: disposeBag) + + viewModel.start() } private func bindSheetPan() { @@ -116,7 +182,7 @@ final class TodayTrackDetailVC: BaseViewController { rootView.sheetView.addGestureRecognizer(pan) pan.rx.event.subscribe(onNext: { [weak self] gesture in guard let self, let constraint = self.rootView.sheetHeightConstraint else { return } - let minH: CGFloat = 220 + kSafeBottomMargin + let minH: CGFloat = 200 + kSafeBottomMargin let maxH: CGFloat = UIScreen.main.bounds.height * 0.7 switch gesture.state { case .began: @@ -126,7 +192,7 @@ final class TodayTrackDetailVC: BaseViewController { constraint.constant = min(maxH, max(minH, self.panStartHeight - dy)) case .ended, .cancelled: let mid = (minH + maxH) / 2 - let target = constraint.constant > mid ? maxH : minH + 100 + let target = constraint.constant > mid ? maxH : minH UIView.animate(withDuration: 0.25) { constraint.constant = target self.rootView.layoutIfNeeded() @@ -137,67 +203,264 @@ final class TodayTrackDetailVC: BaseViewController { }).disposed(by: disposeBag) } - private func drawRoute(_ points: [TrackPoint]) { + private func collapseSheetIfExpanded() { + guard let constraint = rootView.sheetHeightConstraint else { return } + let minH: CGFloat = 200 + kSafeBottomMargin + let maxH: CGFloat = UIScreen.main.bounds.height * 0.7 + let mid = (minH + maxH) / 2 + guard constraint.constant > mid else { return } + UIView.animate(withDuration: 0.25) { + constraint.constant = minH + self.rootView.layoutIfNeeded() + } + } + + private func scrollToSelectedMember(animated: Bool) { + guard let idx = viewModel.members.firstIndex(where: { $0.user_id == viewModel.selectedMemberId.value }), + viewModel.members.count > 0 else { return } + let indexPath = IndexPath(item: idx, section: 0) + guard indexPath.item < rootView.memberCV.numberOfItems(inSection: 0) else { return } + rootView.memberCV.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: animated) + } + + private func setupPlaybackControls() { + rootView.playBtn.addTarget(self, action: #selector(playButtonTapped), for: .touchUpInside) + rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchDown), for: .touchDown) + rootView.progressSlider.addTarget(self, action: #selector(progressSliderValueChanged), for: .valueChanged) + rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchUpInside) + rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchUpOutside) + rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchCancel) + } + + private func drawRoute(_ trips: [ScheduleRecordModel]) { + stopPlayback(resetProgress: true) rootView.mapView.removeOverlays(routeOverlays) routeOverlays.removeAll() if let anns = rootView.mapView.annotations { rootView.mapView.removeAnnotations(anns) } + playbackAnnotation = nil + playbackPath = [] + playbackTotalDistance = 0 - let coords = points - .map { CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lng) } - .filter { CLLocationCoordinate2DIsValid($0) } - guard coords.count >= 2 else { - if let first = coords.first { - rootView.mapView.setCenter(first, animated: true) + let points = trips.flatMap { $0.trajectory_path } + let coords = points.compactMap { point -> CLLocationCoordinate2D? in + let coord = CLLocationCoordinate2D(latitude: point.lat, longitude: point.lng) + guard abs(point.lat) > 0.0001, abs(point.lng) > 0.0001, CLLocationCoordinate2DIsValid(coord) else { + return nil } - return + return coord } - var mutable = coords - if let polyline = MAPolyline(coordinates: &mutable, count: UInt(mutable.count)) { - rootView.mapView.add(polyline) - routeOverlays.append(polyline) - let padding = UIEdgeInsets(top: 160, left: 40, bottom: 360, right: 40) - rootView.mapView.showOverlays(routeOverlays, edgePadding: padding, animated: true) + buildPlaybackPath(coords) + + if coords.count >= 2 { + var mutable = coords + if let polyline = MAPolyline(coordinates: &mutable, count: UInt(mutable.count)) { + rootView.mapView.add(polyline) + routeOverlays.append(polyline) + let padding = UIEdgeInsets(top: 170, left: 40, bottom: 420, right: 40) + rootView.mapView.showOverlays(routeOverlays, edgePadding: padding, animated: true) + } + } else if let first = coords.first { + rootView.mapView.setCenter(first, animated: true) } + if let start = coords.first { let ann = MAPointAnnotation() ann.coordinate = start - ann.title = "始" + ann.title = "start" rootView.mapView.addAnnotation(ann) } - } - - private static func stayTitle(_ stay: StayPoint, isCurrent: Bool) -> String { - let addr = stay.address.isEmpty ? "**" : stay.address - if isCurrent { - return "当前 \(addr)" + if let end = coords.last { + let ann = MAPointAnnotation() + ann.coordinate = end + ann.title = "end" + rootView.mapView.addAnnotation(ann) } - let time = Date(timeIntervalSince1970: TimeInterval(stay.start_time)).toFormat("HH:mm") - return "\(time) \(addr)" + + for stay in trips.flatMap({ $0.stay_points }) { + let coord = CLLocationCoordinate2D(latitude: stay.lat, longitude: stay.lng) + guard abs(stay.lat) > 0.0001, abs(stay.lng) > 0.0001, CLLocationCoordinate2DIsValid(coord) else { + continue + } + let ann = HistoryTrackStayAnnotation() + ann.coordinate = coord + ann.title = "stay" + ann.minutes = max(0, stay.duration_minutes) + rootView.mapView.addAnnotation(ann) + } + + updatePlaybackAnnotation(progress: 0) } - private static func stayTags(_ stay: StayPoint, isCurrent: Bool) -> [String] { - let seconds = max(0, Int(stay.end_time - stay.start_time)) - let hours = seconds / 3600 - let minutes = (seconds % 3600) / 60 - let duration: String - if hours > 0 { - duration = "停留 \(hours)小时\(minutes)分" + private func showStayDuration(at coordinate: CLLocationCoordinate2D, minutes: Int) { + hideStayDuration() + let ann = HistoryTrackDurationAnnotation() + ann.coordinate = coordinate + ann.title = "duration" + ann.text = Self.durationBubbleText(minutes) + rootView.mapView.addAnnotation(ann) + } + + private func hideStayDuration() { + let durations = (rootView.mapView.annotations ?? []).compactMap { $0 as? HistoryTrackDurationAnnotation } + if !durations.isEmpty { + rootView.mapView.removeAnnotations(durations) + } + } + + private func buildPlaybackPath(_ coords: [CLLocationCoordinate2D]) { + var path: [(coordinate: CLLocationCoordinate2D, distance: Double)] = [] + var cumulative: Double = 0 + var last: CLLocationCoordinate2D? + for coord in coords { + if let last { + cumulative += last.distance(to: coord) + } + path.append((coordinate: coord, distance: cumulative)) + last = coord + } + playbackPath = path + playbackTotalDistance = cumulative + } + + // MARK: - Playback + + @objc private func playButtonTapped() { + if !rootView.playBtn.isSelected, playbackProgress >= 1 { + playbackProgress = 0 + rootView.progressSlider.value = 0 + updatePlaybackAnnotation(progress: playbackProgress) + } + rootView.playBtn.isSelected ? pausePlayback() : startPlayback() + } + + @objc private func progressSliderTouchDown() { + pausePlayback() + } + + @objc private func progressSliderValueChanged() { + playbackProgress = Double(rootView.progressSlider.value) + updatePlaybackAnnotation(progress: playbackProgress) + } + + @objc private func progressSliderTouchEnded() { + playbackProgress = Double(rootView.progressSlider.value) + updatePlaybackAnnotation(progress: playbackProgress) + } + + private func startPlayback() { + guard playbackPath.count >= 2, playbackProgress < 1 else { return } + let distance = max(playbackTotalDistance, 1) + playbackDuration = max(minPlaybackDuration, min(maxPlaybackDuration, distance / playbackReplaySpeed)) + rootView.playBtn.isSelected = true + playbackLastTick = Date() + displayLink?.invalidate() + displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink)) + displayLink?.add(to: .main, forMode: .common) + } + + private func pausePlayback() { + rootView.playBtn.isSelected = false + displayLink?.invalidate() + displayLink = nil + playbackLastTick = nil + } + + private func stopPlayback(resetProgress: Bool) { + pausePlayback() + if resetProgress { + playbackProgress = 0 + rootView.progressSlider.value = 0 + } + } + + @objc private func handleDisplayLink() { + let now = Date() + let delta = playbackLastTick.map { now.timeIntervalSince($0) } ?? 0 + playbackLastTick = now + playbackProgress = min(1, playbackProgress + delta / playbackDuration) + rootView.progressSlider.value = Float(playbackProgress) + updatePlaybackAnnotation(progress: playbackProgress) + if playbackProgress >= 1 { + pausePlayback() + } + } + + private func updatePlaybackAnnotation(progress: Double) { + guard let coordinate = playbackCoordinate(at: progress) else { return } + if let annotation = playbackAnnotation { + annotation.coordinate = coordinate } else { - duration = "停留 \(minutes)分" + let annotation = HistoryTrackPlaybackAnnotation() + annotation.coordinate = coordinate + playbackAnnotation = annotation + rootView.mapView.addAnnotation(annotation) } - if isCurrent { - return [duration, "停留中..."] + } + + private func playbackCoordinate(at progress: Double) -> CLLocationCoordinate2D? { + let clamped = min(max(progress, 0), 1) + let path = playbackPath + guard !path.isEmpty else { return nil } + guard playbackTotalDistance > 0 else { return path.first?.coordinate } + + let target = playbackTotalDistance * clamped + var lo = 0, hi = path.count - 1 + while lo < hi { + let mid = (lo + hi) / 2 + if path[mid].distance < target { + lo = mid + 1 + } else { + hi = mid + } } - return [duration] + let idx = lo + let prev = idx > 0 ? path[idx - 1] : path[0] + let curr = path[idx] + let seg = curr.distance - prev.distance + let ratio = seg > 0 ? max(0, min(1, (target - prev.distance) / seg)) : 0 + return prev.coordinate.interpolate(to: curr.coordinate, ratio: ratio) + } + + private static func stayPointImage() -> UIImage { + let size = CGSize(width: 14, height: 14) + UIGraphicsBeginImageContextWithOptions(size, false, 0) + defer { UIGraphicsEndImageContext() } + UIColor(hexStr: "#293445").setFill() + UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill() + UIColor.white.setFill() + UIBezierPath(ovalIn: CGRect(x: 4, y: 4, width: 6, height: 6)).fill() + return UIGraphicsGetImageFromCurrentImageContext() ?? UIImage() + } + + private static func durationBubbleText(_ minutes: Int) -> String { + let hours = minutes / 60 + let mins = minutes % 60 + if hours > 0 { + return "\(hours)小时\(mins)分" + } + return "\(mins)分钟" + } + + private static func playbackAvatarImage(_ image: UIImage) -> UIImage? { + let size = CGSize(width: 36, height: 36) + UIGraphicsBeginImageContextWithOptions(size, false, 0) + defer { UIGraphicsEndImageContext() } + let rect = CGRect(origin: .zero, size: size) + UIColor.white.setFill() + UIBezierPath(ovalIn: rect).fill() + let imageRect = rect.insetBy(dx: 2, dy: 2) + UIBezierPath(ovalIn: imageRect).addClip() + image.draw(in: imageRect) + return UIGraphicsGetImageFromCurrentImageContext() } } // MARK: - Collection / Table -extension TodayTrackDetailVC: UICollectionViewDataSource, UICollectionViewDelegate { +extension TodayTrackDetailVC: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { if collectionView === rootView.memberCV { return viewModel.members.count @@ -230,33 +493,44 @@ extension TodayTrackDetailVC: UICollectionViewDataSource, UICollectionViewDelega let item = viewModel.dateItems[indexPath.item] viewModel.selectedDateKey.accept(item.key) } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + if collectionView === rootView.memberCV { + return CGSize(width: 61, height: 90) + } + return rootView.dateItemSize() + } } extension TodayTrackDetailVC: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - stayList.count + tripList.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: TodayTrackStayCell = tableView.dequeueReusableCell(for: indexPath) - let stay = stayList[indexPath.row] + let cell: TodayTrackTripCell = tableView.dequeueReusableCell(for: indexPath) + let trip = tripList[indexPath.row] let isFirst = indexPath.row == 0 - let isLast = indexPath.row == stayList.count - 1 - cell.configure( - title: Self.stayTitle(stay, isCurrent: isFirst), - tags: Self.stayTags(stay, isCurrent: isFirst), - isFirst: isFirst, - isLast: isLast - ) + let isLast = indexPath.row == tripList.count - 1 + cell.configure(trip: trip, isFirst: isFirst, isLast: isLast) + cell.onViewTapped = { [weak self] in + self?.selectTripAndCollapse(trip) + } return cell } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let stay = stayList[indexPath.row] - let coord = CLLocationCoordinate2D(latitude: stay.lat, longitude: stay.lng) - guard CLLocationCoordinate2DIsValid(coord) else { return } - rootView.mapView.setCenter(coord, animated: true) - rootView.mapView.setZoomLevel(16, animated: true) + guard tripList.indices.contains(indexPath.row) else { return } + selectTripAndCollapse(tripList[indexPath.row]) + } + + private func selectTripAndCollapse(_ trip: ScheduleRecordModel) { + viewModel.selectTrip(trip) + collapseSheetIfExpanded() } } @@ -264,21 +538,159 @@ extension TodayTrackDetailVC: MAMapViewDelegate { func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! { guard let polyline = overlay as? MAPolyline else { return nil } let renderer = MAPolylineRenderer(polyline: polyline) - renderer?.strokeColor = UIColor(hexStr: "#5EC8FF") + renderer?.strokeColor = UIColor(hexStr: "#58EDFF") renderer?.lineWidth = 6 + renderer?.lineJoinType = kMALineJoinRound + renderer?.lineCapType = kMALineCapRound return renderer } func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! { guard !(annotation is MAUserLocation) else { return nil } - let id = "todayTrack.ann" - var view = mapView.dequeueReusableAnnotationView(withIdentifier: id) - if view == nil { - view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) + + if annotation is HistoryTrackPlaybackAnnotation { + let id = "historyTrack.playback" + var view = mapView.dequeueReusableAnnotationView(withIdentifier: id) + if view == nil { + view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) + } + view?.annotation = annotation + let headPic = viewModel.selectedMember?.displayHeadPic ?? "" + view?.image = Self.playbackAvatarImage(HeadPic.image(for: headPic)) + HeadPic.load(headPic) { [weak view] image in + view?.image = Self.playbackAvatarImage(image) + } + view?.centerOffset = CGPoint(x: 0, y: -18) + view?.zIndex = 20 + return view } - view?.annotation = annotation - view?.image = UIImage(systemName: "mappin.circle.fill") - view?.centerOffset = CGPoint(x: 0, y: -12) - return view + + if annotation is HistoryTrackStayAnnotation { + let id = "historyTrack.stay" + var view = mapView.dequeueReusableAnnotationView(withIdentifier: id) + if view == nil { + view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) + } + view?.annotation = annotation + view?.image = Self.stayPointImage() + view?.centerOffset = .zero + view?.zIndex = 8 + view?.canShowCallout = false + return view + } + + if let duration = annotation as? HistoryTrackDurationAnnotation { + let id = "historyTrack.duration" + var view = mapView.dequeueReusableAnnotationView(withIdentifier: id) as? HistoryTrackDurationAnnotationView + if view == nil { + view = HistoryTrackDurationAnnotationView(annotation: annotation, reuseIdentifier: id) + } + view?.annotation = annotation + view?.configure(text: duration.text) + return view + } + + guard let pointAnn = annotation as? MAPointAnnotation else { return nil } + if pointAnn.title == "start" { + let id = "historyTrack.start" + var view = mapView.dequeueReusableAnnotationView(withIdentifier: id) + if view == nil { + view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) + } + view?.annotation = annotation + view?.image = UIImage(named: "Home/HistoryTrack/start") + view?.centerOffset = CGPoint(x: 0, y: -14) + view?.zIndex = 10 + return view + } + if pointAnn.title == "end" { + let id = "historyTrack.end" + var view = mapView.dequeueReusableAnnotationView(withIdentifier: id) + if view == nil { + view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) + } + view?.annotation = annotation + view?.image = UIImage(named: "Home/HistoryTrack/end") + view?.centerOffset = CGPoint(x: 0, y: -14) + view?.zIndex = 10 + return view + } + return nil + } + + func mapView(_ mapView: MAMapView!, didSelect view: MAAnnotationView!) { + guard let stay = view.annotation as? HistoryTrackStayAnnotation else { return } + mapView.deselectAnnotation(stay, animated: false) + suppressMapTapHide = true + showStayDuration(at: stay.coordinate, minutes: stay.minutes) + DispatchQueue.main.async { [weak self] in + self?.suppressMapTapHide = false + } + } + + func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) { + guard !suppressMapTapHide else { return } + hideStayDuration() + } +} + +private final class HistoryTrackPlaybackAnnotation: MAPointAnnotation {} + +private final class HistoryTrackStayAnnotation: MAPointAnnotation { + var minutes: Int = 0 +} + +private final class HistoryTrackDurationAnnotation: MAPointAnnotation { + var text: String = "" +} + +private final class HistoryTrackDurationAnnotationView: MAAnnotationView { + private let bubble = UIView() + private let iconView = UIImageView(image: UIImage(named: "Home/HistoryTrack/clock")) + private let label = UILabel() + + override init(annotation: MAAnnotation?, reuseIdentifier: String?) { + super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) + bubble.backgroundColor = .white + bubble.layer.cornerRadius = 16 + bubble.layer.shadowColor = UIColor.black.withAlphaComponent(0.12).cgColor + bubble.layer.shadowOpacity = 1 + bubble.layer.shadowOffset = CGSize(width: 0, height: 2) + bubble.layer.shadowRadius = 6 + addSubview(bubble) + iconView.contentMode = .scaleAspectFit + bubble.addSubview(iconView) + label.font = .systemFont(ofSize: 13, weight: .semibold) + label.textColor = UIColor(hexStr: "#293445") + bubble.addSubview(label) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(text: String) { + label.text = text + let textWidth = ceil((text as NSString).size(withAttributes: [.font: label.font as Any]).width) + let width = max(96, textWidth + 44) + bounds = CGRect(x: 0, y: 0, width: width, height: 32) + centerOffset = CGPoint(x: 0, y: -36) + bubble.frame = bounds + iconView.frame = CGRect(x: 10, y: 7, width: 18, height: 18) + label.frame = CGRect(x: 32, y: 0, width: width - 40, height: 32) + } +} + +private extension CLLocationCoordinate2D { + func distance(to coordinate: CLLocationCoordinate2D) -> CLLocationDistance { + let fromLocation = CLLocation(latitude: latitude, longitude: longitude) + let toLocation = CLLocation(latitude: coordinate.latitude, longitude: longitude) + return fromLocation.distance(from: toLocation) + } + + func interpolate(to coordinate: CLLocationCoordinate2D, ratio: Double) -> CLLocationCoordinate2D { + let clampedRatio = min(max(ratio, 0), 1) + return CLLocationCoordinate2D( + latitude: latitude + (coordinate.latitude - latitude) * clampedRatio, + longitude: longitude + (coordinate.longitude - longitude) * clampedRatio + ) } } diff --git a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift index bec2ea4b..91589403 100644 --- a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift +++ b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift @@ -8,6 +8,12 @@ import AMapNaviKit final class TodayTrackDetailView: UIView { + lazy var headerBgImg: UIImageView = { + let view = UIImageView() + view.image = UIImage(named: "Home/HistoryTrack/header_bg") + return view + }() + let mapView: MAMapView = { let mv = MAMapView() mv.zoomLevel = 14 @@ -19,16 +25,14 @@ final class TodayTrackDetailView: UIView { let backBtn: UIButton = { let btn = UIButton(type: .custom) - btn.setImage(UIImage(systemName: "chevron.left"), for: .normal) - btn.tintColor = UIColor(hexStr: "#293445") - btn.backgroundColor = .white - btn.cornerRadius = 10 + btn.setImage(UIImage(named: "Common/back"), for: .normal) + btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 10, bottom: 20, right: 40) return btn }() let titleLab: UILabel = { let lab = UILabel() - lab.text = "今日轨迹" + lab.text = "历史轨迹" lab.font = .systemFont(ofSize: 17, weight: .semibold) lab.textColor = UIColor(hexStr: "#293445") lab.textAlignment = .center @@ -43,16 +47,43 @@ final class TodayTrackDetailView: UIView { let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) cv.backgroundColor = .white cv.showsHorizontalScrollIndicator = false - cv.contentInset = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + cv.contentInset = UIEdgeInsets(top: 3, left: 6, bottom: 0, right: 6) cv.register(GroupMemberListCell.self) cv.cornerRadius = 16 return cv }() + let playBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "Home/HistoryTrack/play"), for: .normal) + btn.setImage(UIImage(named: "Home/HistoryTrack/pause"), for: .selected) + return btn + }() + + let progressSlider: HistoryTrackSlider = { + let slider = HistoryTrackSlider() + slider.minimumValue = 0 + slider.maximumValue = 1 + slider.value = 0 + slider.minimumTrackTintColor = UIColor(hexStr: "#16B3FF") + slider.maximumTrackTintColor = UIColor(hexStr: "#D9EEFF") + let thumb = UIImage(named: "Home/HistoryTrack/slider_thumb") + slider.setThumbImage(thumb, for: .normal) + slider.setThumbImage(thumb, for: .highlighted) + return slider + }() + + private let playbackBar: UIView = { + let v = UIView() + v.backgroundColor = UIColor.white.withAlphaComponent(0.96) + v.cornerRadius = 12 + return v + }() + let sheetView: UIView = { let v = UIView() - v.backgroundColor = .white - v.layer.cornerRadius = 20 + v.backgroundColor = UIColor(hexStr: "#FAFAFA") + v.layer.cornerRadius = 30 v.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] v.clipsToBounds = true return v @@ -65,16 +96,35 @@ final class TodayTrackDetailView: UIView { return v }() + let datePrevBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "Home/HistoryTrack/date_prev"), for: .normal) + return btn + }() + + let dateNextBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "Home/HistoryTrack/date_next"), for: .normal) + return btn + }() + + private let dateBar: UIView = { + let v = UIView() + v.backgroundColor = UIColor(hexStr: "#ECEFF3") + v.cornerRadius = 16 + return v + }() + lazy var dateCV: UICollectionView = { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .horizontal - layout.estimatedItemSize = CGSize(width: 56, height: 32) - layout.minimumLineSpacing = 8 + layout.minimumLineSpacing = 0 + layout.minimumInteritemSpacing = 0 + layout.sectionInset = .zero let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) - cv.backgroundColor = UIColor(hexStr: "#F3F4F6") + cv.backgroundColor = .clear cv.showsHorizontalScrollIndicator = false - cv.contentInset = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) - cv.cornerRadius = 18 + cv.isPagingEnabled = true cv.register(TodayTrackDateCell.self) return cv }() @@ -84,9 +134,9 @@ final class TodayTrackDetailView: UIView { tv.separatorStyle = .none tv.backgroundColor = .clear tv.showsVerticalScrollIndicator = false - tv.register(TodayTrackStayCell.self) + tv.register(TodayTrackTripCell.self) tv.rowHeight = UITableView.automaticDimension - tv.estimatedRowHeight = 72 + tv.estimatedRowHeight = 128 return tv }() @@ -116,12 +166,17 @@ final class TodayTrackDetailView: UIView { addSubview(mapView) mapView.layoutChain.edges() + addSubview(headerBgImg) + headerBgImg.layoutChain + .edges(excludingEdge: .bottom) + .heightToWidth(160/375) + addSubview(backBtn) backBtn.layoutChain .top(kStatusBarHeight + 6) .left(15) - .width(36) - .height(36) + .width(42) + .height(42) addSubview(titleLab) titleLab.layoutChain @@ -135,7 +190,7 @@ final class TodayTrackDetailView: UIView { .height(100) addSubview(sheetView) - let sheetH = sheetView.heightAnchor.constraint(equalToConstant: 320 + kSafeBottomMargin) + let sheetH = sheetView.heightAnchor.constraint(equalToConstant: 200 + kSafeBottomMargin) sheetH.isActive = true sheetHeightConstraint = sheetH sheetView.translatesAutoresizingMaskIntoConstraints = false @@ -145,6 +200,26 @@ final class TodayTrackDetailView: UIView { sheetView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) + addSubview(playbackBar) + playbackBar.layoutChain + .bottomToTopOfView(sheetView, offset: -12) + .edgesHorzontal(15) + .height(35) + + playbackBar.addSubview(playBtn) + playbackBar.addSubview(progressSlider) + + playBtn.layoutChain + .left(12) + .centerY() + .width(28) + .height(28) + progressSlider.layoutChain + .leftToRightOfView(playBtn, offset: 10) + .right(16) + .centerY() + .height(28) + sheetView.addSubview(handleView) handleView.layoutChain .top(8) @@ -152,22 +227,42 @@ final class TodayTrackDetailView: UIView { .width(36) .height(5) - sheetView.addSubview(dateCV) + sheetView.addSubview(dateBar) + dateBar.layoutChain + .topToBottomOfView(handleView, offset: 10) + .edgesHorzontal(10) + .height(32) + + dateBar.addSubview(datePrevBtn) + dateBar.addSubview(dateNextBtn) + dateBar.addSubview(dateCV) + + datePrevBtn.layoutChain + .left(4) + .centerY() + .width(28) + .height(28) + dateNextBtn.layoutChain + .right(4) + .centerY() + .width(28) + .height(28) dateCV.layoutChain - .topToBottomOfView(handleView, offset: 12) - .edgesHorzontal(15) - .height(36) + .leftToRightOfView(datePrevBtn, offset: 0) + .rightToLeftOfView(dateNextBtn, offset: 0) + .top() + .bottom() sheetView.addSubview(timelineTV) timelineTV.layoutChain - .topToBottomOfView(dateCV, offset: 12) - .edgesHorzontal(15) - .bottom(kSafeBottomMargin + 8) + .topToBottomOfView(dateBar, offset: 12) + .edgesHorzontal(10) + .bottom() sheetView.addSubview(emptyLab) emptyLab.layoutChain .centerX() - .topToBottomOfView(dateCV, offset: 40) + .topToBottomOfView(dateBar, offset: 40) } func cleanupMap() { @@ -175,6 +270,39 @@ final class TodayTrackDetailView: UIView { mapView.removeOverlays(mapView.overlays) mapView.delegate = nil } + + func dateItemSize() -> CGSize { + let width = dateCV.bounds.width + let height = dateCV.bounds.height + let itemWidth = width > 0 ? width / 5 : 50 + let itemHeight = height > 0 ? height : 32 + return CGSize(width: itemWidth, height: itemHeight) + } + + func scrollDates(direction: CGFloat) { + let page = dateCV.bounds.width + guard page > 0 else { return } + let offset = dateCV.contentOffset.x + direction * page + let maxX = max(0, dateCV.contentSize.width - page) + let x = min(max(0, offset), maxX) + dateCV.setContentOffset(CGPoint(x: x, y: 0), animated: true) + } + + func scrollDatesToEnd(animated: Bool) { + dateCV.layoutIfNeeded() + let page = dateCV.bounds.width + let maxX = max(0, dateCV.contentSize.width - page) + dateCV.setContentOffset(CGPoint(x: maxX, y: 0), animated: animated) + } +} + +final class HistoryTrackSlider: UISlider { + override func trackRect(forBounds bounds: CGRect) -> CGRect { + var rect = super.trackRect(forBounds: bounds) + rect.size.height = 4 + rect.origin.y = (bounds.height - 4) / 2 + return rect + } } // MARK: - Date cell @@ -182,88 +310,233 @@ final class TodayTrackDetailView: UIView { final class TodayTrackDateCell: UICollectionViewCell { private let titleLab = UILabel() + private let selectedBg = UIView() + override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = .clear - contentView.cornerRadius = 14 - titleLab.font = .systemFont(ofSize: 13, weight: .semibold) + selectedBg.cornerRadius = 12 + titleLab.font = .systemFont(ofSize: 12, weight: .medium) titleLab.textAlignment = .center - contentView.addSubview(titleLab) - titleLab.layoutChain.edges(UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)) + titleLab.adjustsFontSizeToFitWidth = true + titleLab.minimumScaleFactor = 0.8 + titleLab.lineBreakMode = .byClipping + contentView.addSubview(selectedBg) + selectedBg.addSubview(titleLab) + selectedBg.layoutChain.edgesVertical(4).edgesHorzontal() + titleLab.layoutChain.edges() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(title: String, selected: Bool) { titleLab.text = title - titleLab.textColor = selected ? UIColor(hexStr: "#293445") : UIColor(hexStr: "#9CA3AF") - contentView.backgroundColor = selected ? .white : .clear + titleLab.textColor = selected ? UIColor(hexStr: "#293445") : UIColor(hexStr: "#767676") + selectedBg.backgroundColor = selected ? .white : .clear } } -// MARK: - Stay cell +// MARK: - Trip cell -final class TodayTrackStayCell: UITableViewCell { +final class TodayTrackTripCell: UITableViewCell { + + var onViewTapped: (() -> Void)? + + private let marker = UIImageView() private let dot = UIView() - private let line = UIView() - private let titleLab = UILabel() - private let tagStack = UIStackView() + private let lineTop = UIView() + private let lineBottom = UIView() + private let card = UIView() + private let minutesValue = UILabel() + private let minutesUnit = UILabel() + private let distanceValue = UILabel() + private let distanceUnit = UILabel() + private let divider = UIView() + private let viewBtn = UIButton(type: .custom) + private let startPrefix = UILabel() + private let startLab = UILabel() + private let routeLine = UIView() + private let endPrefix = UILabel() + private let endLab = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.backgroundColor = .clear - - dot.backgroundColor = UIColor(hexStr: "#16B3FF") - dot.cornerRadius = 5 - contentView.addSubview(dot) - dot.layoutChain.top(8).left().width(10).height(10) - - line.backgroundColor = UIColor(hexStr: "#E5E7EB") - contentView.addSubview(line) - line.layoutChain - .topToBottomOfView(dot, offset: 2) - .centerX(dot) - .width(2) - .bottom() - - titleLab.font = .systemFont(ofSize: 14, weight: .medium) - titleLab.textColor = UIColor(hexStr: "#293445") - titleLab.numberOfLines = 2 - contentView.addSubview(titleLab) - titleLab.layoutChain - .top(4) - .leftToRightOfView(dot, offset: 12) - .right() - - tagStack.axis = .horizontal - tagStack.spacing = 8 - contentView.addSubview(tagStack) - tagStack.layoutChain - .topToBottomOfView(titleLab, offset: 8) - .leftToView(titleLab) - .bottom(12) + setupUI() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func configure(title: String, tags: [String], isFirst: Bool, isLast: Bool) { - titleLab.text = title - line.isHidden = isLast - dot.backgroundColor = isFirst ? UIColor(hexStr: "#16B3FF") : UIColor(hexStr: "#9CA3AF") - tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() } - for tag in tags { - let lab = UILabel() - lab.text = tag - lab.font = .systemFont(ofSize: 12, weight: .medium) - lab.textColor = UIColor(hexStr: "#6B7280") - let wrap = UIView() - wrap.backgroundColor = UIColor(hexStr: "#F3F4F6") - wrap.cornerRadius = 8 - wrap.addSubview(lab) - lab.layoutChain.edges(UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8)) - tagStack.addArrangedSubview(wrap) + func configure(trip: ScheduleRecordModel, isFirst: Bool, isLast: Bool) { + if isFirst { + marker.image = UIImage(named: "Home/HistoryTrack/start") + marker.isHidden = false + dot.isHidden = true + } else if isLast { + marker.image = UIImage(named: "Home/HistoryTrack/end") + marker.isHidden = false + dot.isHidden = true + } else { + marker.image = nil + marker.isHidden = true + dot.isHidden = false } + lineTop.isHidden = isFirst + lineBottom.isHidden = isLast + + minutesValue.text = "\(max(0, trip.duration_minutes))" + distanceValue.text = Self.distanceText(trip.distance_km) + + let startTime = trip.start_time.isoStringToCustom(trip.start_time, format: "HH:mm") ?? "" + let endTime = trip.end_time.isoStringToCustom(trip.end_time, format: "HH:mm") ?? "" + startLab.text = Self.placeText(trip.start_address, time: startTime) + endLab.text = Self.placeText(trip.end_address, time: endTime) + } + + private func setupUI() { + lineTop.backgroundColor = UIColor(hexStr: "#D1D5DB") + lineBottom.backgroundColor = UIColor(hexStr: "#D1D5DB") + marker.contentMode = .scaleAspectFit + dot.backgroundColor = UIColor(hexStr: "#293445") + dot.cornerRadius = 4 + dot.isHidden = true + + contentView.addSubview(lineTop) + contentView.addSubview(lineBottom) + contentView.addSubview(marker) + contentView.addSubview(dot) + + marker.layoutChain + .centerY() + .left(4) + .width(20) + .height(20) + dot.layoutChain + .centerX(marker) + .centerY(marker) + .width(8) + .height(8) + lineTop.layoutChain + .top() + .centerX(marker) + .width(2) + .bottomToTopOfView(marker, offset: 0) + lineBottom.layoutChain + .topToBottomOfView(marker, offset: 0) + .centerX(marker) + .width(2) + .bottom() + + card.backgroundColor = .white + card.cornerRadius = 16 + contentView.addSubview(card) + card.layoutChain + .top(4) + .leftToRightOfView(marker, offset: 10) + .right(6) + .bottom(8) + + minutesValue.font = .systemFont(ofSize: 20, weight: .semibold) + minutesValue.textColor = UIColor(hexStr: "#16B3FF") + minutesUnit.text = "分钟" + minutesUnit.font = .systemFont(ofSize: 12, weight: .medium) + minutesUnit.textColor = UIColor(hexStr: "#16B3FF") + distanceValue.font = .systemFont(ofSize: 20, weight: .semibold) + distanceValue.textColor = UIColor(hexStr: "#16B3FF") + distanceUnit.text = "公里" + distanceUnit.font = .systemFont(ofSize: 12, weight: .medium) + distanceUnit.textColor = UIColor(hexStr: "#16B3FF") + divider.backgroundColor = UIColor(hexStr: "#D9EEFF") + + viewBtn.setTitle("查看", for: .normal) + viewBtn.setTitleColor(UIColor(hexStr: "#9CA3AF"), for: .normal) + viewBtn.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) + viewBtn.addTarget(self, action: #selector(viewTapped), for: .touchUpInside) + + startPrefix.text = "起点:" + startPrefix.font = .systemFont(ofSize: 13, weight: .medium) + startPrefix.textColor = UIColor(hexStr: "#9CA3AF") + startLab.font = .systemFont(ofSize: 13, weight: .medium) + startLab.textColor = UIColor(hexStr: "#293445") + startLab.numberOfLines = 2 + + routeLine.backgroundColor = UIColor(hexStr: "#D4F3FF") + routeLine.cornerRadius = 8 + + endPrefix.text = "终点:" + endPrefix.font = .systemFont(ofSize: 13, weight: .medium) + endPrefix.textColor = UIColor(hexStr: "#9CA3AF") + endLab.font = .systemFont(ofSize: 13, weight: .medium) + endLab.textColor = UIColor(hexStr: "#293445") + endLab.numberOfLines = 2 + + [minutesValue, minutesUnit, divider, distanceValue, distanceUnit, viewBtn, + startPrefix, startLab, routeLine, endPrefix, endLab].forEach { card.addSubview($0) } + + minutesValue.layoutChain.top(14).left(14) + minutesUnit.layoutChain.leftToRightOfView(minutesValue, offset: 4).centerY(minutesValue) + divider.layoutChain + .leftToRightOfView(minutesUnit, offset: 10) + .centerY(minutesValue) + .width(1) + .height(16) + distanceValue.layoutChain.leftToRightOfView(divider, offset: 10).centerY(minutesValue) + distanceUnit.layoutChain.leftToRightOfView(distanceValue, offset: 4).centerY(minutesValue) + viewBtn.layoutChain.right(8).centerY(minutesValue).width(44).height(28) + + startPrefix.layoutChain + .topToBottomOfView(minutesValue, offset: 14) + .left(14) + startLab.layoutChain + .topToView(startPrefix) + .leftToRightOfView(startPrefix, offset: 0) + .right(12) + + routeLine.layoutChain + .topToBottomOfView(startLab, offset: 4) + .centerX(startLab) + .width(3) + .height(10) + + endPrefix.layoutChain + .topToBottomOfView(routeLine, offset: 4) + .leftToView(startPrefix) + endLab.layoutChain + .topToView(endPrefix) + .leftToRightOfView(endPrefix, offset: 0) + .right(12) + .bottom(14) + } + + @objc private func viewTapped() { + onViewTapped?() + } + + private static func distanceText(_ km: Double) -> String { + if km < 0.05 { return "0.0" } + if abs(km - km.rounded()) < 0.05 { + return "\(Int(km.rounded()))" + } + return String(format: "%.1f", km) + } + + private static func placeText(_ address: TripAddress?, time: String) -> String { + let district = address?.district ?? "" + let street = address?.street ?? "" + var place = "" + if !district.isEmpty, !street.isEmpty { + place = "\(district) \(street)" + } else if !street.isEmpty { + place = street + } else if !district.isEmpty { + place = district + } else { + place = address?.formatted_address ?? "" + } + if time.isEmpty { return place } + if place.isEmpty { return time } + return "\(place) \(time)" } } diff --git a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift index 55bf4302..bf54888e 100644 --- a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift +++ b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift @@ -19,105 +19,143 @@ final class TodayTrackDetailViewModel { let members: [GroupMemberModel] let selectedMemberId: BehaviorRelay let selectedDateKey: BehaviorRelay - let stayPoints = BehaviorRelay<[StayPoint]>(value: []) - let trajectoryPoints = BehaviorRelay<[TrackPoint]>(value: []) + let trips = BehaviorRelay<[ScheduleRecordModel]>(value: []) + let selectedTripId = BehaviorRelay(value: nil) + let displayedTrips = BehaviorRelay<[ScheduleRecordModel]>(value: []) let isEmpty = BehaviorRelay(value: true) let loading = BehaviorRelay(value: false) private let disposeBag = DisposeBag() let dateItems: [DateItem] + private var requestToken = UUID() init(members: [GroupMemberModel], selectedUserId: String, initialDate: Date = Date()) { self.members = members self.dateItems = Self.makeDateItems(around: initialDate) self.selectedMemberId = BehaviorRelay(value: selectedUserId) - self.selectedDateKey = BehaviorRelay(value: Self.dayKey(initialDate)) + let defaultKey = Self.dayKey(Self.yesterday(of: initialDate)) + self.selectedDateKey = BehaviorRelay(value: defaultKey) bindSelection() - reloadPlayback() + } + + func start() { + reloadTrips() + } + + var selectedMember: GroupMemberModel? { + members.first(where: { $0.user_id == selectedMemberId.value }) + } + + func selectTrip(_ trip: ScheduleRecordModel) { + selectedTripId.accept(trip.id) + applyDisplayedTrips() + } + + func dateMillis(for key: String) -> Int64 { + guard let item = dateItems.first(where: { $0.key == key }) else { + return Self.dayStartMillis(Self.yesterday(of: Date())) + } + return Self.dayStartMillis(item.date) } private func bindSelection() { - selectedMemberId - .asObservable() - .skip(1) - .subscribe(onNext: { [weak self] _ in - self?.reloadPlayback() - }) - .disposed(by: disposeBag) - - selectedDateKey - .asObservable() - .skip(1) - .subscribe(onNext: { [weak self] _ in - self?.reloadPlayback() - }) - .disposed(by: disposeBag) + Observable.merge( + selectedMemberId.asObservable().skip(1).map { _ in () }, + selectedDateKey.asObservable().skip(1).map { _ in () } + ) + .subscribe(onNext: { [weak self] in + self?.reloadTrips() + }) + .disposed(by: disposeBag) } - private func reloadPlayback() { + private func reloadTrips() { let userId = selectedMemberId.value let dateKey = selectedDateKey.value - loadPlayback(userId: userId, dateKey: dateKey) + selectedTripId.accept(nil) + trips.accept([]) + displayedTrips.accept([]) + isEmpty.accept(false) + loadTrips(userId: userId, dateKey: dateKey) } - private func loadPlayback(userId: String, dateKey: String) { + private func loadTrips(userId: String, dateKey: String) { if userId.isEmpty { - clearPlayback() + applyTrips([]) return } + let token = UUID() + requestToken = token loading.accept(true) - DrivingService.playback(user_id: userId, date: dateKey) + let date = dateMillis(for: dateKey) + UserService.phoneUsageTrips(userId: userId, date: date) .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] response in - self?.applyPlayback(response) + guard let self, self.requestToken == token else { return } + self.applyTrips(response.trips) }, onError: { [weak self] _ in - self?.clearPlayback() + guard let self, self.requestToken == token else { return } + self.applyTrips([]) }) .disposed(by: disposeBag) } - private func applyPlayback(_ response: ScheduleRecordListResponse) { - let trips = response.list - let stays = trips.flatMap { $0.stay_points }.sorted { lhs, rhs in - lhs.start_time > rhs.start_time + private func applyTrips(_ list: [ScheduleRecordModel]) { + let sorted = list.sorted { $0.start_time < $1.start_time } + trips.accept(sorted) + if selectedTripId.value == nil || !sorted.contains(where: { $0.id == selectedTripId.value }) { + selectedTripId.accept(sorted.first?.id) } - let path = trips.flatMap { $0.trajectory_path } - stayPoints.accept(stays) - trajectoryPoints.accept(path) - isEmpty.accept(stays.isEmpty && path.isEmpty) + applyDisplayedTrips() loading.accept(false) } - private func clearPlayback() { - stayPoints.accept([]) - trajectoryPoints.accept([]) - isEmpty.accept(true) - loading.accept(false) + private func applyDisplayedTrips() { + let all = trips.value + if let id = selectedTripId.value, let one = all.first(where: { $0.id == id }) { + displayedTrips.accept([one]) + } else { + displayedTrips.accept([]) + } + isEmpty.accept(all.isEmpty) + } + + private static func yesterday(of date: Date) -> Date { + var calendar = shanghaiCalendar() + let today = calendar.startOfDay(for: date) + return calendar.date(byAdding: .day, value: -1, to: today) ?? today } private static func dayKey(_ date: Date) -> String { - date.toFormat("yyyy-MM-dd") + date.in(region: shanghaiRegion()).toFormat("yyyy-MM-dd") + } + + static func dayStartMillis(_ date: Date) -> Int64 { + let start = shanghaiCalendar().startOfDay(for: date) + return Int64((start.timeIntervalSince1970 * 1000).rounded()) } private static func makeDateItems(around date: Date) -> [DateItem] { - let calendar = Calendar.current + var calendar = shanghaiCalendar() let today = calendar.startOfDay(for: date) var items: [DateItem] = [] - for offset in -4...0 { + for offset in (-30 ... -1) { guard let day = calendar.date(byAdding: .day, value: offset, to: today) else { continue } - let title: String - switch offset { - case 0: - title = "今天" - case -1: - title = "昨天" - default: - title = day.toFormat("MM-dd") - } + let title = offset == -1 ? "昨天" : day.in(region: shanghaiRegion()).toFormat("MM-dd") items.append(DateItem(date: day, title: title, key: dayKey(day))) } return items } + + private static func shanghaiCalendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current + return calendar + } + + private static func shanghaiRegion() -> Region { + Region(zone: TimeZone(identifier: "Asia/Shanghai") ?? .current, locale: Locale(identifier: "zh_CN")) + } } diff --git a/QuickLocation/Section/Home/UnlockRequestPopView.swift b/QuickLocation/Section/Home/UnlockRequestPopView.swift index cd4c4524..f991a5d2 100644 --- a/QuickLocation/Section/Home/UnlockRequestPopView.swift +++ b/QuickLocation/Section/Home/UnlockRequestPopView.swift @@ -6,8 +6,26 @@ import UIKit struct UnlockRequestDisplayItem { - let member: GroupMemberModel + let member: GroupMemberModel? + let fallbackName: String + let headPic: String let lockStartTime: Date + let os: String + let groupKey: String + let userId: String + let tokens: [String] + + var displayName: String { + if let name = member?.showName.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { + return name + } + let fallback = fallbackName.trimmingCharacters(in: .whitespacesAndNewlines) + return fallback.isEmpty ? "圈子成员" : fallback + } + + var displayHeadPic: String { + HeadPic.resolved(headPic, member?.displayHeadPic) + } } final class UnlockRequestPopView: UIView { @@ -17,13 +35,13 @@ final class UnlockRequestPopView: UIView { ) private var requests: [UnlockRequestDisplayItem] = [] - private var onUnlock: ((UnlockRequestDisplayItem) -> Void)? + private var onUnlock: ((UnlockRequestDisplayItem, @escaping (Bool) -> Void) -> Void)? private var onReject: ((UnlockRequestDisplayItem) -> Void)? private var timer: Timer? static func show( requests: [UnlockRequestDisplayItem], - onUnlock: ((UnlockRequestDisplayItem) -> Void)? = nil, + onUnlock: ((UnlockRequestDisplayItem, @escaping (Bool) -> Void) -> Void)? = nil, onReject: ((UnlockRequestDisplayItem) -> Void)? = nil ) { guard !requests.isEmpty, let window = kKeyWindow else { return } @@ -134,17 +152,27 @@ final class UnlockRequestPopView: UIView { guard requests.indices.contains(index) else { return } let item = requests[index] if isUnlock { - onUnlock?(item) - } else { - onReject?(item) + onUnlock?(item) { [weak self] success in + guard success, let self else { return } + if let idx = self.requests.firstIndex(where: { + $0.userId == item.userId && $0.groupKey == item.groupKey + }) { + self.removeRequest(at: idx) + } + } + return } + onReject?(item) + removeRequest(at: index) + } + private func removeRequest(at index: Int) { + guard requests.indices.contains(index) else { return } requests.remove(at: index) guard !requests.isEmpty else { Self.dismiss() return } - collectionView.performBatchUpdates { collectionView.deleteItems(at: [IndexPath(item: index, section: 0)]) } completion: { [weak self] _ in @@ -426,9 +454,8 @@ private final class UnlockRequestCardCell: UICollectionViewCell { func configure(item: UnlockRequestDisplayItem) { self.item = item - let image = item.member.userIcon - avatarView.image = image.size == .zero ? UIImage(named: "Common/default_avatar") : image - nameLabel.text = item.member.nick_name.isEmpty ? "圈子成员" : item.member.nick_name + avatarView.setHeadPic(item.displayHeadPic) + nameLabel.text = item.displayName refreshElapsedTime() } diff --git a/QuickLocation/Section/Launch/LaunchViewController.swift b/QuickLocation/Section/Launch/LaunchViewController.swift index 481851c3..e5a96cc9 100644 --- a/QuickLocation/Section/Launch/LaunchViewController.swift +++ b/QuickLocation/Section/Launch/LaunchViewController.swift @@ -26,7 +26,7 @@ class LaunchViewController: BaseViewController { super.viewDidLoad() // Do any additional setup after loading the view. - view.backgroundColor = UIColor(hexStr: "#E0F2FF") + view.backgroundColor = .white setupLayout() experienceBtn.rx.tap.subscribe(onNext: { _ in @@ -72,13 +72,53 @@ class LaunchViewController: BaseViewController { DLAlert.show(title: error.localizedDescription, defaultTitle: "重试") { [weak self] in guard let this = self else { return } + #if DEBUG || AdHoc + this.showServerSwitchAlert() + #endif this.getUserConfig() } }).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) { - model.temp ? self.navigateAfterDelay() : self.getUserIMToken() + self.navigateAfterDelay() } private func showVersionUpgrade(_ upgrade: VersionUpgradeModel, model: UserConfigModel) { @@ -102,18 +142,6 @@ class LaunchViewController: BaseViewController { ) } - /// 获取用户IM Token - func getUserIMToken() { - UserService.imToken().subscribe(onNext: { response in - guard let data = response.data, let token = data["token"] as? String else { return } - AppContextManager.shared.imToken = token - GroupIMService.shared.login { _ in } - self.navigateAfterDelay() - }, onError: { [weak self] (error) in - self?.navigateAfterDelay() - }).disposed(by: disposeBag) - } - // MARK: - Init init() { super.init(nibName: nil, bundle: nil) diff --git a/QuickLocation/Section/LockDistract/LockDistractVC.swift b/QuickLocation/Section/LockDistract/LockDistractVC.swift index c0baf5a0..2cdcb43a 100644 --- a/QuickLocation/Section/LockDistract/LockDistractVC.swift +++ b/QuickLocation/Section/LockDistract/LockDistractVC.swift @@ -11,9 +11,11 @@ final class LockDistractVC: BaseViewController { private var rootView: LockDistractView! private var groupModel: GroupModel? + private var ownedGroups: [GroupInfoModel] = [] private var activeGroupKey: String = "" private var members: [GroupMemberModel] = [] private var selectedIndex = 0 + private var lockAppsDisposable: Disposable? override var isNavigationBarHidden: Bool { true } @@ -52,23 +54,58 @@ final class LockDistractVC: BaseViewController { }) .disposed(by: disposeBag) - // 未获取 / 锁定:本期不接业务 rootView.notFetchedView.rx.tap .subscribe(onNext: { _ in LockDistractPermissionPopView.show() }).disposed(by: disposeBag) + + rootView.lockBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.performPrimaryAction() + }) + .disposed(by: disposeBag) + + NotificationCenter.default.rx.notification(.lockDistractAppsDidChange) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] _ in + self?.loadLockableApps() + }) + .disposed(by: disposeBag) } private func loadGroupMembers() { GroupService.groupInfo().subscribe(onNext: { [weak self] response in guard let self, let model = response.model else { return } - self.groupModel = model - self.activeGroupKey = model.default_group_key - self.updateGroupName() - self.loadUsers(for: self.activeGroupKey) + self.applyOwnedGroups(from: model) }).disposed(by: disposeBag) } + private func applyOwnedGroups(from model: GroupModel) { + let owned = model.groups.filter(\.is_owner) + guard !owned.isEmpty else { + DLToast.show(text: "只有圈主才能使用此功能") { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + return + } + groupModel = model + ownedGroups = owned + if owned.contains(where: { $0.group_key == model.default_group_key }) { + activeGroupKey = model.default_group_key + } else { + activeGroupKey = owned[0].group_key + } + updateGroupName() + loadUsers(for: activeGroupKey) + } + + private func ownedGroupModelForPicker() -> GroupModel? { + guard var model = groupModel else { return nil } + model.groups = ownedGroups + model.default_group_key = activeGroupKey + return model + } + private func loadUsers(for groupKey: String) { guard !groupKey.isEmpty else { applyMembers([]) @@ -92,6 +129,7 @@ final class LockDistractVC: BaseViewController { selectedIndex = 0 refreshMember() rootView.setTodayLockCount(0) + loadLockableApps() } private func stepMember(_ delta: Int) { @@ -99,17 +137,22 @@ final class LockDistractVC: BaseViewController { let count = members.count selectedIndex = (selectedIndex + delta + count) % count refreshMember() + loadLockableApps() } private func refreshMember() { guard members.indices.contains(selectedIndex) else { - rootView.configureMember(name: " ", avatar: nil) + rootView.configureMember(name: " ") rootView.configureLocked(false) return } let m = members[selectedIndex] - let name = m.remark.isEmpty ? m.nick_name : m.remark - rootView.configureMember(name: name, avatar: m.userIcon) + let name = m.showName + rootView.configureMember( + name: name, + headPic: m.displayHeadPic, + isCurrentUser: m.user_id.trimmed == AppContextManager.shared.userId.trimmed + ) rootView.configureLocked(false) } @@ -119,12 +162,12 @@ final class LockDistractVC: BaseViewController { } private func switchGroup() { - guard let groupModel else { + guard let pickerModel = ownedGroupModelForPicker() else { loadGroupMembers() return } GroupListPopView.show( - groupModel: groupModel, + groupModel: pickerModel, currentMembers: members, selectedKey: activeGroupKey ) { [weak self] groupKey in @@ -134,4 +177,153 @@ final class LockDistractVC: BaseViewController { self.loadUsers(for: key) } } + + private var selectedMember: GroupMemberModel? { + members.indices.contains(selectedIndex) ? members[selectedIndex] : nil + } + + private func loadLockableApps() { + lockAppsDisposable?.dispose() + rootView.configureLockableApps([]) + guard let member = selectedMember, !activeGroupKey.isEmpty else { return } + let targetUserId = member.user_id + let requestGroupKey = activeGroupKey + lockAppsDisposable = UserService.phoneLockApps(userId: targetUserId, groupKey: requestGroupKey) + .subscribe(onNext: { [weak self] response in + guard let self, + self.activeGroupKey == requestGroupKey, + self.selectedMember?.user_id == targetUserId else { return } + let apps = response.model?.apps ?? [] + let lockedCount = apps.filter(\.locked).count + let action = self.primaryAction( + locked: lockedCount > 0, + fromUser: response.resolvedFromUser, + member: member + ) + self.rootView.configureLockableApps(apps) + self.rootView.configureLocked(lockedCount > 0, action: action) + self.rootView.setTodayLockCount(lockedCount) + }, onError: { [weak self] error in + guard let self, + self.activeGroupKey == requestGroupKey, + self.selectedMember?.user_id == targetUserId else { return } + self.rootView.configureLockableApps([]) + self.rootView.configureLocked(false) + self.rootView.setTodayLockCount(0) + DLToast.show(text: error.gatewayMessage ?? "获取可锁应用失败") + }) + lockAppsDisposable?.disposed(by: disposeBag) + } + + private func primaryAction( + locked: Bool, + fromUser: String, + member: GroupMemberModel + ) -> LockDistractPrimaryAction { + guard locked else { return .lock } + let currentUserId = AppContextManager.shared.userId.trimmed + let lockOwnerId = fromUser.trimmed + if !currentUserId.isEmpty, lockOwnerId == currentUserId { + return .unlock + } + if member.user_id.trimmed == currentUserId { + return .requestUnlock + } + return .hidden + } + + private func requestUnlockForLockedApps() { + let lockedApps = rootView.lockableApps.filter(\.locked) + let tokens = lockedApps.map(\.token).filter { !$0.isEmpty } + let os = lockedApps.first(where: { !$0.os.isEmpty })?.os ?? "ios" + guard !activeGroupKey.isEmpty, !tokens.isEmpty else { + DLToast.show(text: "锁定数据缺失") + return + } + DLToast.showLoading() + UserService.requestPhoneUnlock(os: os, groupKey: activeGroupKey, tokens: tokens) + .subscribe(onNext: { _ in + DLToast.show(text: "已向圈主发送解锁请求") + }, onError: { error in + DLToast.show(text: error.gatewayMessage ?? "发送失败") + }) + .disposed(by: disposeBag) + } + + private func unlockLockedApps() { + guard let member = selectedMember else { + DLToast.show(text: "解锁数据缺失") + return + } + let lockedApps = rootView.lockableApps.filter(\.locked) + let tokens = lockedApps.map(\.token).filter { !$0.isEmpty } + let os = lockedApps.first(where: { !$0.os.isEmpty })?.os ?? "ios" + guard !activeGroupKey.isEmpty, !tokens.isEmpty else { + DLToast.show(text: "解锁数据缺失") + return + } + DLToast.showLoading() + rootView.lockBtn.isEnabled = false + UserService.phoneUnlock( + os: os, + groupKey: activeGroupKey, + userId: member.user_id, + tokens: tokens + ) + .subscribe(onNext: { [weak self] _ in + self?.rootView.lockBtn.isEnabled = true + DLToast.show(text: "已解锁") + self?.loadLockableApps() + }, onError: { [weak self] error in + self?.rootView.lockBtn.isEnabled = true + DLToast.show(text: error.gatewayMessage ?? "解锁失败") + }) + .disposed(by: disposeBag) + } + + private func performPrimaryAction() { + switch rootView.primaryAction { + case .requestUnlock: + requestUnlockForLockedApps() + case .unlock: + unlockLockedApps() + case .lock: + lockSelectedApps() + case .hidden: + break + } + } + + private func lockSelectedApps() { + guard let member = selectedMember, !activeGroupKey.isEmpty else { + DLToast.show(text: "请选择要锁定的成员") + return + } + let apps = rootView.selectedLockApps + let tokens = apps.map(\.token).filter { !$0.isEmpty } + guard !tokens.isEmpty else { + DLToast.show(text: "请选择要锁定的应用") + return + } + let os = apps.first(where: { !$0.os.isEmpty })?.os ?? "ios" + DLToast.showLoading() + rootView.lockBtn.isEnabled = false + UserService.phoneLock( + os: os, + groupKey: activeGroupKey, + userId: member.user_id, + tokens: tokens, + iconIndex: rootView.selectedLockIconIndex, + message: rootView.lockMessage + ) + .subscribe(onNext: { [weak self] _ in + self?.rootView.lockBtn.isEnabled = true + DLToast.show(text: "锁定成功") + self?.loadLockableApps() + }, onError: { [weak self] error in + self?.rootView.lockBtn.isEnabled = true + DLToast.show(text: error.gatewayMessage ?? "锁定失败") + }) + .disposed(by: disposeBag) + } } diff --git a/QuickLocation/Section/LockDistract/LockDistractView.swift b/QuickLocation/Section/LockDistract/LockDistractView.swift index f6d5e4b4..378980ef 100644 --- a/QuickLocation/Section/LockDistract/LockDistractView.swift +++ b/QuickLocation/Section/LockDistract/LockDistractView.swift @@ -6,6 +6,14 @@ import UIKit import RxSwift import RxCocoa +import Kingfisher + +enum LockDistractPrimaryAction { + case lock + case requestUnlock + case unlock + case hidden +} final class LockDistractView: UIView { @@ -23,7 +31,10 @@ final class LockDistractView: UIView { private(set) var selectedLockIconIndex = 0 private(set) var selectedAppIndexes: Set = [] + private(set) var lockableApps: [PhoneLockAppItem] = [] private(set) var isLocked = false + private(set) var isCurrentUser = false + private(set) var primaryAction: LockDistractPrimaryAction = .lock private let copyMaxLength = 20 override init(frame: CGRect) { @@ -36,9 +47,20 @@ final class LockDistractView: UIView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func configureMember(name: String, avatar: UIImage?) { + func configureMember( + name: String, + avatar: UIImage? = nil, + headPic: String? = nil, + isCurrentUser: Bool = false + ) { + self.isCurrentUser = isCurrentUser memberNameLab.text = name.isEmpty ? " " : name - memberAvatar.image = avatar ?? UIImage(named: "Common/default_avatar") + if let headPic, !headPic.isEmpty { + memberAvatar.setHeadPic(headPic) + } else { + memberAvatar.image = avatar ?? HeadPic.placeholder + } + updateLockedEmptyImage() } func setTodayLockCount(_ count: Int) { @@ -72,21 +94,55 @@ final class LockDistractView: UIView { switchGroupBtn.sizeToFit() } - func configureLocked(_ locked: Bool) { + func configureLocked(_ locked: Bool, action: LockDistractPrimaryAction? = nil) { isLocked = locked + primaryAction = action ?? (locked ? .requestUnlock : .lock) applyLockedState() } + func configureLockableApps(_ apps: [PhoneLockAppItem]) { + lockableApps = apps + selectedAppIndexes.removeAll() + notFetchedView.isHidden = !apps.isEmpty + appCollectionView.reloadData() + } + + var selectedLockApps: [PhoneLockAppItem] { + lockableApps.enumerated().compactMap { index, app in + selectedAppIndexes.contains(index) ? app : nil + } + } + + var lockMessage: String { + (copyTF.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + private func applyLockedState() { + updateLockedEmptyImage() avatarLockView.isHidden = !isLocked styleSectionTitle.isHidden = isLocked styleCard.isHidden = isLocked lockedEmptyView.isHidden = !isLocked appCollectionView.reloadData() - tipLab.text = isLocked - ? "已被锁定情况下,先解锁才能锁定哟~" - : "锁定功能只有在圈主的情况下才能使用哟~" - lockBtn.setTitle(isLocked ? "请求解锁" : "锁定", for: .normal) + lockBtn.isHidden = primaryAction == .hidden + switch primaryAction { + case .lock: + tipLab.text = "锁定功能只有在圈主的情况下才能使用哟~" + lockBtn.setTitle("锁定", for: .normal) + case .requestUnlock: + tipLab.text = "已被其他圈主锁定,请先申请解锁哟~" + lockBtn.setTitle("请求解锁", for: .normal) + case .unlock: + tipLab.text = "已被你锁定,解锁后才能重新设置哟~" + lockBtn.setTitle("解锁", for: .normal) + case .hidden: + tipLab.text = "该成员的APP已被其他圈主锁定" + } + } + + private func updateLockedEmptyImage() { + let imageName = isCurrentUser ? "LockDistract/locked_self" : "LockDistract/locked_empty" + lockedEmptyView.image = UIImage(named: imageName) } private func setupUI() { @@ -146,36 +202,36 @@ final class LockDistractView: UIView { statsRow.layoutChain .topToBottomOfView(navBgView, offset: -48) .edgesHorzontal(16) - .height(120) + .height(122) appsSectionTitle.layoutChain - .topToBottomOfView(statsRow, offset: 22) + .topToBottomOfView(statsRow, offset: 14) .left(20) appsCard.layoutChain - .topToBottomOfView(appsSectionTitle, offset: 12) + .topToBottomOfView(appsSectionTitle, offset: 10) .edgesHorzontal(16) appsHeaderRow.layoutChain - .top(16) - .edgesHorzontal(14) - .height(28) + .top(10) + .edgesHorzontal(18) + .height(17) appCollectionView.layoutChain .topToBottomOfView(appsHeaderRow, offset: 12) .edgesHorzontal(8) .height(78) - .bottom(16) + .bottom() bottomStack.layoutChain - .topToBottomOfView(appsCard, offset: 22) + .topToBottomOfView(appsCard, offset: 11) .edgesHorzontal(16) - .bottom(24) + .bottom(20) styleSectionTitle.layoutChain.left(4) lockedEmptyView.layoutChain .centerX() - .height(180) +// .height(150) } private func setupCopyLimit() { @@ -226,7 +282,6 @@ final class LockDistractView: UIView { let sv = UIScrollView() sv.contentInsetAdjustmentBehavior = .never sv.showsVerticalScrollIndicator = false - sv.alwaysBounceVertical = true sv.keyboardDismissMode = .onDrag return sv }() @@ -403,8 +458,8 @@ final class LockDistractView: UIView { left.layoutChain.left().centerY() row.addSubview(notFetchedView) - notFetchedView.layoutChain.right().centerY().height(28) - row.layoutChain.height(28) + notFetchedView.layoutChain.right().centerY().height(17) + row.layoutChain.height(17) return row }() @@ -475,7 +530,7 @@ final class LockDistractView: UIView { let stack = UIStackView() stack.axis = .vertical stack.alignment = .fill - stack.spacing = 12 + stack.spacing = 10 return stack }() @@ -493,12 +548,12 @@ final class LockDistractView: UIView { wallLab.font = .systemFont(ofSize: 13, weight: .medium) wallLab.textColor = UIColor(hexStr: "#8A94A6") card.addSubview(wallLab) - wallLab.layoutChain.top(16).left(14) + wallLab.layoutChain.top(12).left(18) card.addSubview(lockIconCV) lockIconCV.layoutChain .topToBottomOfView(wallLab, offset: 10) - .edgesHorzontal(14) + .edgesHorzontal(18) .height(74) let copyLab = UILabel() @@ -506,7 +561,7 @@ final class LockDistractView: UIView { copyLab.font = .systemFont(ofSize: 13, weight: .medium) copyLab.textColor = UIColor(hexStr: "#8A94A6") card.addSubview(copyLab) - copyLab.layoutChain.topToBottomOfView(lockIconCV, offset: 16).left(14) + copyLab.layoutChain.topToBottomOfView(lockIconCV, offset: 16).left(18) let input = UIView() input.backgroundColor = UIColor(hexStr: "#F5F6F8") @@ -514,7 +569,7 @@ final class LockDistractView: UIView { card.addSubview(input) input.layoutChain .topToBottomOfView(copyLab, offset: 10) - .edgesHorzontal(14) + .edgesHorzontal(18) .height(44) .bottom(16) @@ -570,7 +625,7 @@ final class LockDistractView: UIView { let lab = UILabel() lab.text = "锁定功能只有在圈主的情况下才能使用哟~" lab.font = .systemFont(ofSize: 12, weight: .regular) - lab.textColor = UIColor(hexStr: "#00ADFE") + lab.textColor = UIColor(hexStr: "#293445") lab.numberOfLines = 0 return lab }() @@ -615,16 +670,20 @@ final class LockDistractView: UIView { extension LockDistractView: UICollectionViewDataSource, UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - collectionView.tag == 1 ? 1 : lockIconNames.count + collectionView.tag == 1 ? max(lockableApps.count, 1) : lockIconNames.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { if collectionView.tag == 1 { let cell = collectionView.dequeueReusableCell(for: indexPath) as LockDistractAppCell + let hasApps = !lockableApps.isEmpty + let iconURL = hasApps ? lockableApps[indexPath.item].icon : nil + let locked = hasApps && lockableApps[indexPath.item].locked cell.configure( - image: UIImage(named: "LockDistract/app_unknown"), + iconURL: iconURL, selected: selectedAppIndexes.contains(indexPath.item), - showsCheck: false + showsCheck: hasApps && !isLocked, + locked: locked ) return cell } @@ -638,7 +697,7 @@ extension LockDistractView: UICollectionViewDataSource, UICollectionViewDelegate func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { if collectionView.tag == 1 { - guard !isLocked, indexPath.item > 0 else { return } + guard !isLocked, !lockableApps.isEmpty, !lockableApps[indexPath.item].locked else { return } if selectedAppIndexes.contains(indexPath.item) { selectedAppIndexes.remove(indexPath.item) } else { @@ -672,17 +731,62 @@ final class LockDistractAppCell: UICollectionViewCell { override init(frame: CGRect) { super.init(frame: frame) contentView.addSubview(iconView) + contentView.addSubview(lockDimView) + contentView.addSubview(lockIconView) contentView.addSubview(checkBtn) iconView.layoutChain.top().centerX().width(48).height(48) + lockDimView.layoutChain + .topToView(iconView) + .leftToView(iconView) + .rightToView(iconView) + .bottomToView(iconView) + lockIconView.layoutChain + .centerX(iconView) + .centerY(iconView) + .width(14) + .height(14) checkBtn.layoutChain.topToBottomOfView(iconView, offset: 6).centerX().width(16).height(16) } + private let lockDimView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.3) + view.layer.cornerRadius = 12 + view.clipsToBounds = true + view.isHidden = true + view.isUserInteractionEnabled = false + return view + }() + + private let lockIconView: UIImageView = { + let iv = UIImageView(image: UIImage(named: "LockDistract/app_list_lock")) + iv.contentMode = .scaleAspectFit + iv.isHidden = true + return iv + }() + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func configure(image: UIImage?, selected: Bool, showsCheck: Bool) { - iconView.image = image + override func prepareForReuse() { + super.prepareForReuse() + iconView.kf.cancelDownloadTask() + iconView.image = nil + lockDimView.isHidden = true + lockIconView.isHidden = true + } + + func configure(iconURL: String?, selected: Bool, showsCheck: Bool, locked: Bool = false) { + let placeholder = UIImage(named: "LockDistract/app_unknown") + if let iconURL, let url = URL(string: iconURL) { + iconView.kf.setImage(with: url, placeholder: placeholder) + } else { + iconView.kf.cancelDownloadTask() + iconView.image = placeholder + } checkBtn.isSelected = selected checkBtn.isHidden = !showsCheck + lockDimView.isHidden = !locked + lockIconView.isHidden = !locked } } @@ -696,7 +800,8 @@ final class LockDistractWallpaperCell: UICollectionViewCell { private let imageView: UIImageView = { let iv = UIImageView() - iv.contentMode = .scaleAspectFit + iv.contentMode = .scaleAspectFill + iv.cornerRadius = 10 iv.clipsToBounds = true return iv }() @@ -709,8 +814,8 @@ final class LockDistractWallpaperCell: UICollectionViewCell { imageView.layoutChain .centerY() .centerX() - .width(48) - .height(48) + .width(40) + .height(40) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/QuickLocation/Section/Login/LoginView.swift b/QuickLocation/Section/Login/LoginView.swift index c50986ee..86a9b38a 100644 --- a/QuickLocation/Section/Login/LoginView.swift +++ b/QuickLocation/Section/Login/LoginView.swift @@ -65,13 +65,13 @@ class LoginView: UIView { } private func setupUI() { -// addSubview(bgMaskImage) + addSubview(bgMaskImage) addSubview(backBtn) addSubview(guestLoginButton) - addSubview(welcomeView) - welcomeView.addSubview(welcomeLineView) - welcomeView.addSubview(welcomeLabel) - welcomeView.addSubview(welcomeTitleLab) +// addSubview(welcomeView) +// welcomeView.addSubview(welcomeLineView) +// welcomeView.addSubview(welcomeLabel) +// welcomeView.addSubview(welcomeTitleLab) addSubview(inputContainerView) inputContainerView.addSubview(phoneInputView) @@ -89,7 +89,7 @@ class LoginView: UIView { agreementView.addSubview(agreementTV) agreementView.addSubview(agreementLabel) -// bgMaskImage.layoutChain.edges() + bgMaskImage.layoutChain.edges(excludingEdge: .bottom).heightToWidth(320/375) backBtn.layoutChain .top(54) @@ -107,23 +107,23 @@ class LoginView: UIView { .centerY() .edgesHorzontal() - welcomeView.layoutChain - .bottomToTopOfView(inputContainerView, offset: -100) - .left(38) - - welcomeLineView.layoutChain - .edges(excludingEdge: .right) - .width(4) - - welcomeLabel.layoutChain - .top() - .leftToRightOfView(welcomeLineView, offset: 12) - - welcomeTitleLab.layoutChain - .topToBottomOfView(welcomeLabel) - .leftToView(welcomeLabel) - .right() - .bottom() +// welcomeView.layoutChain +// .bottomToTopOfView(inputContainerView, offset: -100) +// .left(38) +// +// welcomeLineView.layoutChain +// .edges(excludingEdge: .right) +// .width(4) +// +// welcomeLabel.layoutChain +// .top() +// .leftToRightOfView(welcomeLineView, offset: 12) +// +// welcomeTitleLab.layoutChain +// .topToBottomOfView(welcomeLabel) +// .leftToView(welcomeLabel) +// .right() +// .bottom() phoneInputView.layoutChain .edges(excludingEdge: .bottom) @@ -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: 20, bottom: 20, right: 20) + btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 100, bottom: 100, right: 20) return btn }() diff --git a/QuickLocation/Section/Login/LoginViewModel.swift b/QuickLocation/Section/Login/LoginViewModel.swift index 0a765559..63a96552 100644 --- a/QuickLocation/Section/Login/LoginViewModel.swift +++ b/QuickLocation/Section/Login/LoginViewModel.swift @@ -25,6 +25,7 @@ enum LoginSessionHandler { guard let model = response.model else { return } NotificationCenter.default.post(name: .invalidatePopupQueue, object: nil) Defaults[\.loginToken] = model.token + AppContextManager.shared.saveAccount(model) RelationStore.shared.preload() DLToast.showSuccess(text: "登录成功") DispatchQueue.main.async { diff --git a/QuickLocation/Section/Login/OneTapLoginView.swift b/QuickLocation/Section/Login/OneTapLoginView.swift index d90c8744..59bc8a03 100644 --- a/QuickLocation/Section/Login/OneTapLoginView.swift +++ b/QuickLocation/Section/Login/OneTapLoginView.swift @@ -57,7 +57,7 @@ class OneTapLoginView: UIView { } private func setupUI() { -// addSubview(bgMaskImage) + addSubview(bgMaskImage) addSubview(backBtn) addSubview(inputContainerView) // inputContainerView.addSubview(welcomeLabel) @@ -70,7 +70,7 @@ class OneTapLoginView: UIView { agreementView.addSubview(agreementLabel) agreementView.addSubview(agreementTV) -// bgMaskImage.layoutChain.edges() + bgMaskImage.layoutChain.edges(excludingEdge: .bottom).heightToWidth(320/375) backBtn.layoutChain .top(54) diff --git a/QuickLocation/Section/Map/CircleMember.swift b/QuickLocation/Section/Map/CircleMember.swift index a72671c0..e328b3c0 100644 --- a/QuickLocation/Section/Map/CircleMember.swift +++ b/QuickLocation/Section/Map/CircleMember.swift @@ -25,8 +25,8 @@ extension CircleMember { /// 从 GroupMemberModel 创建地图标注数据 init(member: GroupMemberModel, isOwner: Bool) { id = member.user_id - name = member.nick_name - avatar = member.head_pic + name = member.showName + avatar = member.displayHeadPic isOnline = member.is_online self.isOwner = isOwner let (coord, addr) = Self.parsePosition(member.last_position) diff --git a/QuickLocation/Section/Map/MemberAnnotation.swift b/QuickLocation/Section/Map/MemberAnnotation.swift index e6ff690d..01b14731 100644 --- a/QuickLocation/Section/Map/MemberAnnotation.swift +++ b/QuickLocation/Section/Map/MemberAnnotation.swift @@ -8,7 +8,7 @@ import Foundation import AMapNaviKit final class MemberAnnotation: NSObject, MAAnnotation { - let member: CircleMember + var member: CircleMember var coordinate: CLLocationCoordinate2D var title: String? { member.name } diff --git a/QuickLocation/Section/Map/MemberAnnotationView.swift b/QuickLocation/Section/Map/MemberAnnotationView.swift index a3fee5ef..b70a66ec 100644 --- a/QuickLocation/Section/Map/MemberAnnotationView.swift +++ b/QuickLocation/Section/Map/MemberAnnotationView.swift @@ -169,11 +169,7 @@ final class MemberAnnotationView: MAAnnotationView { isUserInteractionEnabled = true canShowCallout = false - if let img = UIImage(named: "UserIcon/\(member.avatar)") { - avatarImageView.image = img - } else { - avatarImageView.image = UIImage(named: "Common/default_avatar") - } + avatarImageView.setHeadPic(member.avatar) avatarImageView.backgroundColor = member.avatar.isEmpty ? .lightGray : .clear dotInnerView.backgroundColor = Self.dotColor(for: member) diff --git a/QuickLocation/Section/Map/Navigation/NavigationView.swift b/QuickLocation/Section/Map/Navigation/NavigationView.swift index 32c50f17..7f254f2a 100644 --- a/QuickLocation/Section/Map/Navigation/NavigationView.swift +++ b/QuickLocation/Section/Map/Navigation/NavigationView.swift @@ -106,7 +106,7 @@ class NavigationView: UIView { } func configure(member: CircleMember, groupName: String = "", groupIcon: String = "") { - avatarImgView.image = UIImage(named: "UserIcon/\(member.avatar)") + avatarImgView.setHeadPic(member.avatar) nameLab.text = member.name locationLab.text = member.address groupIconView.image = UIImage(named: "GroupIcon/\(groupIcon)") diff --git a/QuickLocation/Section/Mine/About/AboutVC.swift b/QuickLocation/Section/Mine/About/AboutVC.swift index 0e3cb438..f68a7a81 100644 --- a/QuickLocation/Section/Mine/About/AboutVC.swift +++ b/QuickLocation/Section/Mine/About/AboutVC.swift @@ -10,6 +10,8 @@ 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) @@ -26,6 +28,9 @@ 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) @@ -57,6 +62,58 @@ final class AboutVC: BaseViewController { .disposed(by: disposeBag) } + @objc private func handleLogoTap() { + 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) + } + + 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 diff --git a/QuickLocation/Section/Mine/About/AboutView.swift b/QuickLocation/Section/Mine/About/AboutView.swift index 459a5be6..528e1b26 100644 --- a/QuickLocation/Section/Mine/About/AboutView.swift +++ b/QuickLocation/Section/Mine/About/AboutView.swift @@ -44,6 +44,7 @@ final class AboutView: UIView { logoImage.contentMode = .scaleAspectFill logoImage.backgroundColor = .lightGray logoImage.cornerRadius = 30 + logoImage.isUserInteractionEnabled = true addSubview(logoImage) logoImage.layoutChain diff --git a/QuickLocation/Section/Mine/MinePhotoWallView.swift b/QuickLocation/Section/Mine/MinePhotoWallView.swift index 24a3bc9f..c14a9a6c 100644 --- a/QuickLocation/Section/Mine/MinePhotoWallView.swift +++ b/QuickLocation/Section/Mine/MinePhotoWallView.swift @@ -469,7 +469,7 @@ final class MinePhotoWallView: UIView { pairLeftLab.isHidden = false pairRightLab.isHidden = members.count < 2 let names = members.map { member -> String in - let name = member.nick_name.trimmingCharacters(in: .whitespacesAndNewlines) + let name = member.showName.trimmingCharacters(in: .whitespacesAndNewlines) return name.isEmpty ? "未设置昵称" : name } pairLeftLab.text = names.first ?? " " @@ -481,7 +481,7 @@ final class MinePhotoWallView: UIView { pairHeartImg.isHidden = true pairLeftLab.isHidden = true pairRightLab.isHidden = true - let nickname = model.nick_name.trimmingCharacters(in: .whitespacesAndNewlines) + let nickname = model.showName.trimmingCharacters(in: .whitespacesAndNewlines) titleLab.text = nickname.isEmpty ? "未设置昵称" : nickname daysLab.text = model.joinDaysDisplay } @@ -623,7 +623,7 @@ final class MinePolaroidCell: UICollectionViewCell { func configure(model: GroupMemberModel, isOwner: Bool, clipColorIndex: Int) { self.isOwner = isOwner self.relationIdx = model.extra.relation_idx - avatarImg.image = model.userIcon + avatarImg.setHeadPic(model.displayHeadPic) ownerTag.isHidden = !isOwner relationIconView.configure(relationIdx: relationIdx) { [weak self] isVisible in guard let self else { return } diff --git a/QuickLocation/Section/Mine/MineViewController.swift b/QuickLocation/Section/Mine/MineViewController.swift index 419d5863..2338fd45 100644 --- a/QuickLocation/Section/Mine/MineViewController.swift +++ b/QuickLocation/Section/Mine/MineViewController.swift @@ -92,6 +92,12 @@ final class MineViewController: BaseViewController { } private func requestGroupMembers() { + let cached = AppContextManager.shared.defaultGroupKey + if !cached.isEmpty { + groupKey = cached + loadPhotoWallMembers(groupKey: cached) + return + } GroupService.groupInfo().subscribe(onNext: { [weak self] response in guard let self = self, let model = response.model else { return } self.groupModel = model @@ -100,21 +106,24 @@ final class MineViewController: BaseViewController { self.reloadPhotoWall(members: [], groupKey: "") return } - GroupService.groupUsers(groupKey: model.default_group_key) - .subscribe(onNext: { [weak self] response in - guard response.isValid(for: model.default_group_key) else { - DLToast.showError(text: response.message ?? "获取圈子成员失败") - return - } - self?.reloadPhotoWall(members: response.list, - groupKey: model.default_group_key) - }, onError: { error in - DLToast.showError(text: error.localizedDescription) - }) - .disposed(by: self.disposeBag) + self.loadPhotoWallMembers(groupKey: model.default_group_key) }).disposed(by: disposeBag) } + private func loadPhotoWallMembers(groupKey: String) { + GroupService.groupUsers(groupKey: groupKey) + .subscribe(onNext: { [weak self] response in + guard response.isValid(for: groupKey) else { + DLToast.showError(text: response.message ?? "获取圈子成员失败") + return + } + self?.reloadPhotoWall(members: response.list, groupKey: groupKey) + }, onError: { error in + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: disposeBag) + } + private func reloadPhotoWall(members: [GroupMemberModel], groupKey: String) { let sorted = viewModel.sortedMembers(members, groupKey: groupKey) photoWallMembers = sorted diff --git a/QuickLocation/Section/Mine/MyProfile/AvatarIconListVC.swift b/QuickLocation/Section/Mine/MyProfile/AvatarIconListVC.swift index ef707b06..3de9b53d 100644 --- a/QuickLocation/Section/Mine/MyProfile/AvatarIconListVC.swift +++ b/QuickLocation/Section/Mine/MyProfile/AvatarIconListVC.swift @@ -19,21 +19,76 @@ class AvatarIconListVC: BaseViewController { } private var iconIndex: String - var onSelectIcon: ((Int) -> Void)? + var onSelectIcon: ((String) -> Void)? override func viewDidLoad() { super.viewDidLoad() + rootView.configure(headPic: iconIndex) - // Do any additional setup after loading the view. - rootView.selectedIndex = iconIndex.integer - rootView.iconCollectionView.delegate = self - - rootView.doneBtn.rx.tap.subscribe(onNext: { _ in - if let onSelectIcon = self.onSelectIcon { - onSelectIcon(self.rootView.selectedIndex) - AppRouter.shared.popOrDismiss() - } + rootView.saveBtn.rx.tap.subscribe(onNext: { [weak self] _ in + guard let self else { return } + let selected = self.rootView.selectedHeadPic + guard !selected.isEmpty else { return } + self.onSelectIcon?(selected) + AppRouter.shared.popOrDismiss() }).disposed(by: disposeBag) + + rootView.onBadgeTap = { [weak self] in + self?.showUploadPicker() + } + } + + private func showUploadPicker() { + PigeonUploadPopView.show( + onAlbum: { [weak self] in + self?.openAlbumPicker() + }, + onCamera: { [weak self] in + self?.openCamera() + } + ) + } + + private func openAlbumPicker() { + let picker = PhotoPickerVC(maxCount: 1) + picker.onConfirm = { [weak self] images in + guard let image = images.first else { return } + self?.uploadAndChangeAvatar(image) + } + present(picker, animated: true) + } + + private func openCamera() { + guard UIImagePickerController.isSourceTypeAvailable(.camera) else { + DLToast.showError(text: "当前设备不支持拍照") + return + } + let vc = CameraCaptureVC(mode: .photo) + vc.onPhoto = { [weak self] image in + self?.dismiss(animated: true) { + self?.uploadAndChangeAvatar(image) + } + } + vc.modalPresentationStyle = .fullScreen + present(vc, animated: true) + } + + private func uploadAndChangeAvatar(_ image: UIImage) { + guard let jpeg = image.jpegData(compressionQuality: 0.8) else { + DLToast.show(text: "图片处理失败") + return + } + DLToast.showLoading() + UploadService.uploadURL(jpeg, kind: .jpeg, scene: "avatar") + .subscribe(onNext: { [weak self] url in + guard let self else { return } + self.rootView.setPreviewImage(image) + self.onSelectIcon?(url) + AppRouter.shared.popOrDismiss() + }, onError: { error in + DLToast.show(text: error.gatewayMessage ?? "更换失败") + }) + .disposed(by: disposeBag) } // MARK: - Init @@ -47,9 +102,3 @@ class AvatarIconListVC: BaseViewController { } } - -extension AvatarIconListVC: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - rootView.selectedIndex = indexPath.row + 1 - } -} diff --git a/QuickLocation/Section/Mine/MyProfile/AvatarIconListView.swift b/QuickLocation/Section/Mine/MyProfile/AvatarIconListView.swift index 08527ec6..12d5005a 100644 --- a/QuickLocation/Section/Mine/MyProfile/AvatarIconListView.swift +++ b/QuickLocation/Section/Mine/MyProfile/AvatarIconListView.swift @@ -6,194 +6,322 @@ // import UIKit -import RxSwift -import RxCocoa class AvatarIconListView: UIView { - var disposeBag = DisposeBag() - var selectedIndex: Int = 1 { didSet { - selectedIconView.image = UIImage(named: "UserIcon/\(selectedIndex)") + updatePreview() iconCollectionView.reloadData() } } - + + private var currentHeadPic: String = "" + + var selectedHeadPic: String { + if selectedIndex > 0 { + return "\(selectedIndex)" + } + return currentHeadPic + } + + let saveBtn = UIButton(type: .custom) + var onBadgeTap: (() -> Void)? + private var iconImgList: [UIImage] = [] - - private func setupRx() { - + private let collectionLayout = CollectionHFlowLayout() + private var lastLayoutWidth: CGFloat = 0 + private var collectionHeightConstraint: NSLayoutConstraint? + private var didScrollToInitialPage = false - } - - private func setupUI() { - addSubview(navBgView) - addSubview(navView) - navView.addRightButton(doneBtn) - addSubview(selectedIconView) - addSubview(titleLab) - addSubview(iconCollectionView) - - navBgView.layoutChain - .edges(excludingEdge: .bottom) - .heightToWidth(160/375) - - navView.layoutChain - .edges(excludingEdge: .bottom) - .height(kNaviHeight) - - selectedIconView.layoutChain - .topToBottomOfView(navView, offset: 30) - .centerX() - .width(80) - .height(80) - - titleLab.layoutChain - .topToBottomOfView(selectedIconView, offset: 39) - .left(40) - - iconCollectionView.layoutChain - .topToBottomOfView(titleLab, offset: 13) - .edgesHorzontal(40) - .bottom(kSafeBottomMargin + 10) - } - - lazy var navBgView: UIImageView = { - let iv = UIImageView() - iv.image = UIImage(named: "Common/navBar_bg_2") - iv.contentMode = .scaleAspectFill - return iv - }() - - lazy var navView: BaseNavigationView = { - let nav = BaseNavigationView(title: "更换图标") - return nav - }() - - lazy var doneBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setTitle("完成", for: .normal) - btn.setTitleColor(ThemeManager.shared.color.titleAuxColor, for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) - btn.extendEdgeInsets = UIEdgeInsets(top: 54, left: 100, bottom: 100, right: 15) - return btn - }() - - lazy var selectedIconView: UIImageView = { - let view = UIImageView() - view.cornerRadius = 40 - return view - }() - - lazy var titleLab: UILabel = { - let label = UILabel() - label.text = "选择头像" - label.font = .systemFont(ofSize: 16, weight: .bold) - label.textColor = ThemeManager.shared.color.titleAuxColor - return label - }() - - lazy var iconCollectionView: UICollectionView = { - let layout = UICollectionViewFlowLayout() - let cvWidth = kScreenWidth - 80 - let spacing: CGFloat = 18 - let itemW = (cvWidth - spacing * 3) / 4 - layout.itemSize = CGSize(width: itemW, height: itemW) - layout.minimumInteritemSpacing = spacing - layout.minimumLineSpacing = 20 + private let columns = 4 + private let rows = 2 + private let hSpacing: CGFloat = 12 + private let vSpacing: CGFloat = 12 + private let cardSideInset: CGFloat = 18 - let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) - cv.backgroundColor = .clear - cv.isScrollEnabled = false - cv.register(GroupIconCell.self) - cv.delegate = self - cv.dataSource = self - return cv - }() - override init(frame: CGRect) { super.init(frame: .zero) backgroundColor = .white - setupUI() - setupRx() - - for i in 1...15 { + for i in 1...16 { if let img = UIImage(named: "UserIcon/\(i)") { iconImgList.append(img) } } + setupUI() + updatePreview() + updatePageControl() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func layoutSubviews() { + super.layoutSubviews() + updateGridLayout(for: bounds.width) + scrollToSelectedPageIfNeeded() + } + + private func setupUI() { + addSubview(navView) + navView.layoutChain.edges(excludingEdge: .bottom).height(kNaviHeight) + + addSubview(previewWrap) + previewWrap.addSubview(selectedIconView) + addSubview(badgeView) + previewWrap.isUserInteractionEnabled = false + addSubview(gridCard) + gridCard.addSubview(titleLab) + gridCard.addSubview(iconCollectionView) + gridCard.addSubview(pageControl) + addSubview(saveBtn) + + previewWrap.layoutChain + .topToBottomOfView(navView, offset: 36) + .centerX() + .width(120) + .height(120) + + selectedIconView.layoutChain.edges(all: 6) + + badgeView.layoutChain + .rightToView(previewWrap, offset: 4) + .bottomToView(previewWrap, offset: 4) + .width(40) + .height(40) + + saveBtn.setTitle("保存", for: .normal) + saveBtn.setTitleColor(.white, for: .normal) + saveBtn.titleLabel?.font = FontManager.boboBold(18) + saveBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + saveBtn.layer.cornerRadius = 28 + saveBtn.clipsToBounds = true + saveBtn.layoutChain + .edgesHorzontal(30) + .bottom(kSafeBottomMargin + 13) + .height(56) + + gridCard.layoutChain + .topToBottomOfView(previewWrap, offset: 48) + .edgesHorzontal(15) + + titleLab.layoutChain + .top(20) + .centerX() + + iconCollectionView.layoutChain + .topToBottomOfView(titleLab, offset: 16) + .edgesHorzontal() + collectionHeightConstraint = iconCollectionView.heightAnchor.constraint(equalToConstant: 148) + collectionHeightConstraint?.isActive = true + + pageControl.layoutChain + .topToBottomOfView(iconCollectionView, offset: 8) + .centerX() + .bottom(10) + .height(16) + + collectionLayout.rows = rows + collectionLayout.colums = columns + collectionLayout.hSpacing = hSpacing + collectionLayout.vSpacing = vSpacing + bringSubviewToFront(badgeView) + } + + func configure(headPic: String) { + currentHeadPic = headPic + if HeadPic.isRemote(headPic) { + selectedIndex = 0 + selectedIconView.setHeadPic(headPic) + } else { + selectedIndex = max(headPic.integer, 1) + } + } + + func setPreviewImage(_ image: UIImage) { + selectedIndex = 0 + selectedIconView.image = image + } + + @objc private func handleBadgeTap() { + onBadgeTap?() + } + + private func updatePreview() { + guard selectedIndex > 0 else { return } + selectedIconView.image = UIImage(named: "UserIcon/\(selectedIndex)") + } + + private func updatePageControl() { + let pageSize = columns * rows + let pageCount = max(Int(ceil(Double(iconImgList.count) / Double(pageSize))), 1) + pageControl.numberOfPages = pageCount + pageControl.isHidden = pageCount <= 1 + } + + private func updateGridLayout(for width: CGFloat) { + let cardWidth = gridCard.bounds.width > 1 + ? gridCard.bounds.width + : max(width - 30, 0) + guard cardWidth > 0, abs(cardWidth - lastLayoutWidth) > 0.5 else { return } + lastLayoutWidth = cardWidth + + let available = cardWidth - cardSideInset * 2 + let itemW = floor((available - hSpacing * CGFloat(columns - 1)) / CGFloat(columns)) + collectionLayout.itemSize = CGSize(width: itemW, height: itemW) + collectionLayout.sectionInset = UIEdgeInsets(top: 0, left: cardSideInset, bottom: 0, right: cardSideInset) + collectionLayout.invalidateLayout() + + let collectionHeight = itemW * CGFloat(rows) + vSpacing * CGFloat(rows - 1) + collectionHeightConstraint?.constant = collectionHeight + } + + private func scrollToSelectedPageIfNeeded() { + guard !didScrollToInitialPage, + iconCollectionView.bounds.width > 1, + collectionLayout.itemSize.width > 0 else { return } + didScrollToInitialPage = true + let pageSize = columns * rows + let page = max((selectedIndex - 1) / pageSize, 0) + pageControl.currentPage = page + iconCollectionView.setContentOffset( + CGPoint(x: CGFloat(page) * iconCollectionView.bounds.width, y: 0), + animated: false + ) + } + + private func updateCurrentPage() { + let width = iconCollectionView.bounds.width + guard width > 0 else { return } + pageControl.currentPage = Int(round(iconCollectionView.contentOffset.x / width)) + } + + lazy var navView: BaseNavigationView = { + BaseNavigationView(title: "") + }() + + private lazy var previewWrap: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 32 + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = 0.08 + view.layer.shadowOffset = CGSize(width: 0, height: 6) + view.layer.shadowRadius = 14 + return view + }() + + lazy var selectedIconView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.layer.cornerRadius = 26 + return view + }() + + private lazy var badgeView: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "Mine/avatar_album_badge"), for: .normal) + btn.imageView?.contentMode = .scaleAspectFit + btn.extendEdgeInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) + btn.addTarget(self, action: #selector(handleBadgeTap), for: .touchUpInside) + return btn + }() + + private lazy var gridCard: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#F5F6F8") + view.layer.cornerRadius = 40 + return view + }() + + private lazy var titleLab: UILabel = { + let label = UILabel() + label.text = "选择头像" + label.font = .systemFont(ofSize: 16, weight: .bold) + label.textColor = ThemeManager.shared.color.titleAuxColor + return label + }() + + private lazy var iconCollectionView: UICollectionView = { + let cv = UICollectionView(frame: .zero, collectionViewLayout: collectionLayout) + cv.backgroundColor = .clear + cv.isPagingEnabled = true + cv.bounces = false + cv.showsHorizontalScrollIndicator = false + cv.contentInsetAdjustmentBehavior = .never + cv.register(AvatarIconCell.self) + cv.delegate = self + cv.dataSource = self + return cv + }() + + private lazy var pageControl: UIPageControl = { + let control = UIPageControl() + control.currentPage = 0 + control.isUserInteractionEnabled = false + control.currentPageIndicatorTintColor = UIColor(hexStr: "#16B3FF") + control.pageIndicatorTintColor = UIColor(hexStr: "#7AD6FF", alpha: 0.4) + return control + }() } -// MARK: - UICollectionViewDelegate, UICollectionViewDataSource extension AvatarIconListView: UICollectionViewDelegate, UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return iconImgList.count + iconImgList.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(for: indexPath) as GroupIconCell - cell.configure(img: iconImgList[indexPath.row], isSelected: selectedIndex == indexPath.row+1) + let cell = collectionView.dequeueReusableCell(for: indexPath) as AvatarIconCell + cell.configure(img: iconImgList[indexPath.item], isSelected: selectedIndex == indexPath.item + 1) return cell } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - selectedIndex = indexPath.row + 1 + selectedIndex = indexPath.item + 1 + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + updateCurrentPage() + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + updateCurrentPage() + } } } -// MARK: - TagCell final class AvatarIconCell: UICollectionViewCell { func configure(img: UIImage, isSelected: Bool) { iconImgView.image = img - selectedMaskView.isHidden = !isSelected - selectedIcon.isHidden = !isSelected + contentView.layer.borderColor = isSelected + ? UIColor(hexStr: "#16B3FF").cgColor + : UIColor.clear.cgColor } - + private func setupUI() { + contentView.backgroundColor = .clear + contentView.layer.cornerRadius = 18 + contentView.layer.borderWidth = 2 + contentView.clipsToBounds = true + contentView.addSubview(iconImgView) - contentView.addSubview(selectedMaskView) - contentView.addSubview(selectedIcon) - - iconImgView.layoutChain.edges() - selectedMaskView.layoutChain.edges() - - selectedIcon.layoutChain - .right(3) - .bottom(4) + iconImgView.layoutChain.edges(all: 2) } - - lazy var iconImgView: UIImageView = { + + private lazy var iconImgView: UIImageView = { let view = UIImageView() view.backgroundColor = .clear view.contentMode = .scaleAspectFill - view.cornerRadius = 10 + view.clipsToBounds = true + view.layer.cornerRadius = 16 return view }() - - lazy var selectedMaskView: UIView = { - let view = UIView() - view.backgroundColor = .black.withAlphaComponent(0.5) - view.cornerRadius = 10 - view.isHidden = true - return view - }() - - lazy var selectedIcon: UIImageView = { - let view = UIImageView() - view.image = UIImage(named: "GroupIcon/selected") - view.isHidden = true - return view - }() - + override init(frame: CGRect) { super.init(frame: frame) setupUI() diff --git a/QuickLocation/Section/Mine/MyProfile/MyProfileVC.swift b/QuickLocation/Section/Mine/MyProfile/MyProfileVC.swift index 9726f834..b38d9c69 100644 --- a/QuickLocation/Section/Mine/MyProfile/MyProfileVC.swift +++ b/QuickLocation/Section/Mine/MyProfile/MyProfileVC.swift @@ -18,9 +18,9 @@ class MyProfileVC: BaseViewController { view = rootView } - private var headPic: Int = AppContextManager.shared.head_pic.integer { + private var headPic: String = AppContextManager.shared.head_pic { didSet { - self.rootView.avatarImgView.image = UIImage(named: "UserIcon/\(headPic)") + self.rootView.avatarImgView.setHeadPic(headPic) } } @@ -47,9 +47,9 @@ class MyProfileVC: BaseViewController { private func reactiveAction() { // 头像 rootView.avatarView.rx.tapGesture.subscribe(onNext: { _ in - let vc = AvatarIconListVC(iconIndex: self.headPic.string) - vc.onSelectIcon = { index in - self.requestSetHeadPic(index: index) + let vc = AvatarIconListVC(iconIndex: self.headPic) + vc.onSelectIcon = { headPic in + self.requestSetHeadPic(headPic) } self.navigationController?.pushViewController(vc, animated: true) }).disposed(by: disposeBag) @@ -77,12 +77,16 @@ class MyProfileVC: BaseViewController { } // MARK: - API 设置头像 - private func requestSetHeadPic(index: Int) { + private func requestSetHeadPic(_ headPic: String) { DLToast.showLoading() - UserService.setHeadPic(index: index).subscribe(onNext: { response in + UserService.setHeadPic(headPic: headPic).subscribe(onNext: { response in DLToast.show(text: "更换成功") - self.headPic = index - // 通知各页面刷新头像 + self.headPic = headPic + if var account = AppContextManager.shared.account { + account.head_pic = headPic + account.avater = HeadPic.isRemote(headPic) ? headPic : "" + AppContextManager.shared.saveAccount(account) + } NotificationCenter.default.post(name: .RefreshUserConfigNotification, object: nil) }, onError: { _ in }).disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Mine/MyProfile/MyProfileView.swift b/QuickLocation/Section/Mine/MyProfile/MyProfileView.swift index 06130e18..43f1101e 100644 --- a/QuickLocation/Section/Mine/MyProfile/MyProfileView.swift +++ b/QuickLocation/Section/Mine/MyProfile/MyProfileView.swift @@ -92,7 +92,7 @@ class MyProfileView: UIView { let view = UIImageView() view.cornerRadius = 40 view.contentMode = .scaleAspectFill - view.image = AppContextManager.shared.avaterIcon + view.setHeadPic(AppContextManager.shared.head_pic) return view }() diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryView.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryView.swift index a480ae56..10e96f80 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryView.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryView.swift @@ -20,8 +20,8 @@ struct PigeonHistoryItem { let image: UIImage? let mediaURL: URL? let localAudioData: Data? - let senderAvatar: UIImage? - let receiverAvatars: [UIImage] + let senderHeadPic: String + let receiverHeadPics: [String] let duration: TimeInterval } @@ -205,7 +205,7 @@ final class PigeonHistoryCell: UITableViewCell { ) { dateLabel.text = item.dateText timeLabel.text = item.timeText - avatarStackView.configure(sender: item.senderAvatar, receivers: item.receiverAvatars) + avatarStackView.configure(sender: item.senderHeadPic, receivers: item.receiverHeadPics) titleLabel.attributedText = Self.makeTitle(kind: item.kind) let isVoice = item.kind == .voice @@ -440,16 +440,17 @@ final class PigeonHistoryAvatarStackView: UIView { fatalError("init(coder:) has not been implemented") } - func configure(sender: UIImage?, receivers: [UIImage]) { - senderView.image = sender ?? UIImage(named: "UserIcon/1") + func configure(sender: String, receivers: [String]) { + senderView.setHeadPic(sender) receiverViews.forEach { $0.removeFromSuperview() } - receiverViews = receivers.map { image in - let view = UIImageView(image: image) + receiverViews = receivers.map { headPic in + let view = UIImageView() view.contentMode = .scaleAspectFill view.clipsToBounds = true view.layer.cornerRadius = 14 view.layer.borderWidth = 2 view.layer.borderColor = UIColor.white.cgColor + view.setHeadPic(headPic) addSubview(view) return view } diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift index 102d17d4..9edb3b93 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift @@ -80,6 +80,13 @@ final class PigeonMessageVC: BaseViewController { } private func loadMembers() { + let cached = AppContextManager.shared.defaultGroupKey + if !cached.isEmpty { + activeGroupKey = cached + updateCurrentGroupDisplay() + loadUsers(for: cached, showLoading: true) + return + } DLToast.showLoading() GroupService.groupInfo().subscribe(onNext: { [weak self] response in guard let self else { return } @@ -138,10 +145,23 @@ final class PigeonMessageVC: BaseViewController { } @objc private func switchGroup() { - guard let groupModel else { - loadMembers() + if let groupModel { + presentGroupPicker(groupModel) return } + DLToast.showLoading() + GroupService.groupInfo().subscribe(onNext: { [weak self] response in + DLToast.dismiss() + guard let self, let model = response.model else { return } + self.groupModel = model + self.presentGroupPicker(model) + }, onError: { error in + DLToast.dismiss() + DLToast.showError(text: error.localizedDescription) + }).disposed(by: disposeBag) + } + + private func presentGroupPicker(_ groupModel: GroupModel) { GroupListPopView.show( groupModel: groupModel, currentMembers: members, diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift index a3bc0728..b07ae8e4 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift @@ -67,7 +67,7 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { private let voiceEditorView = UIView() private let selectedImageView = UIImageView() private let selectedImageMaskView = UIView() - private let imagePlaceholderLabel = UILabel() + private let imagePlaceholderView = UIImageView() private let overlayCaptionBar = UIView() private let overlayCaptionLabel = UILabel() private let copyFooter = UIView() @@ -146,7 +146,7 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { selectedImageView.image = image selectedImageView.isHidden = image == nil selectedImageMaskView.isHidden = image == nil - imagePlaceholderLabel.isHidden = image != nil + imagePlaceholderView.isHidden = image != nil // deleteImageButton.isHidden = image == nil templatePickerView.setSelection( templateID: selectedTemplateID, @@ -459,12 +459,14 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { imageEditorView.addSubview(selectedImageMaskView) selectedImageMaskView.layoutChain.edges() - imagePlaceholderLabel.text = "点击下方添加一张图片" - imagePlaceholderLabel.font = .systemFont(ofSize: scaled(14), weight: .medium) - imagePlaceholderLabel.textColor = UIColor(hexStr: "#9298A1") - imagePlaceholderLabel.textAlignment = .center - imageEditorView.addSubview(imagePlaceholderLabel) - imagePlaceholderLabel.layoutChain.centerX().centerY() + imagePlaceholderView.image = UIImage(named: "PigeonMessage/image_placeholder") + imagePlaceholderView.contentMode = .scaleAspectFit + imageEditorView.addSubview(imagePlaceholderView) + imagePlaceholderView.layoutChain + .centerX() + .centerY() + .width(120) + .height(120) overlayCaptionBar.backgroundColor = UIColor.white.withAlphaComponent(0.9) overlayCaptionBar.layer.cornerRadius = 12 @@ -747,10 +749,8 @@ private final class PigeonMemberCell: UICollectionViewCell { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(member: GroupMemberModel, selected: Bool) { - avatarView.image = member.userIcon.size.width > 0 - ? member.userIcon - : UIImage(named: "Common/default_avatar") - nameLabel.text = member.nick_name + avatarView.setHeadPic(member.displayHeadPic) + nameLabel.text = member.showName onlineDot.backgroundColor = UIColor(hexStr: member.is_online ? "#67E151" : "#AAAAAA") avatarContainer.layer.borderWidth = selected ? 2 : 0 avatarContainer.layer.borderColor = UIColor(hexStr: "#00ADFE").cgColor diff --git a/QuickLocation/Section/Schedule/ScheduleModel.swift b/QuickLocation/Section/Schedule/ScheduleModel.swift index 2563fe0a..d4bba654 100644 --- a/QuickLocation/Section/Schedule/ScheduleModel.swift +++ b/QuickLocation/Section/Schedule/ScheduleModel.swift @@ -70,9 +70,13 @@ struct ScheduleModel: Mappable, Equatable { /// 行程创建者名称 var nick_name: String = "" var head_pic: String = "" + var avater: String = "" + var displayHeadPic: String { + HeadPic.resolved(head_pic, avater) + } /// 头像 var userIcon: UIImage { - UIImage(named: "UserIcon/\(head_pic)") ?? UIImage() + HeadPic.image(for: displayHeadPic) } /// 时间戳 var timestamp: Int64 = 0 @@ -93,7 +97,8 @@ struct ScheduleModel: Mappable, Equatable { id <- map["id"] creator_id <- map["creator_id"] nick_name <- map["nick_name"] - head_pic <- map["head_pic"] + head_pic <- (map["head_pic"], kFlexString) + avater <- (map["avater"], kFlexString) timestamp <- map["timestamp"] is_follow <- map["is_follow"] is_own <- map["is_own"] diff --git a/QuickLocation/Section/Schedule/ScheduleView.swift b/QuickLocation/Section/Schedule/ScheduleView.swift index a889e7d4..b82746e5 100644 --- a/QuickLocation/Section/Schedule/ScheduleView.swift +++ b/QuickLocation/Section/Schedule/ScheduleView.swift @@ -251,7 +251,7 @@ class ScheduleView: UIView { final class ViewedCell: UICollectionViewCell { func configure(_ model: ViewedModel) { - iconView.image = model.userIcon + iconView.setHeadPic(model.displayHeadPic) // 会员权益 blurView.isHidden = AppContextManager.shared.vip > 1 diff --git a/QuickLocation/Section/Schedule/ScheduleViewed/ScheduleViewedView.swift b/QuickLocation/Section/Schedule/ScheduleViewed/ScheduleViewedView.swift index e764b796..55dac498 100644 --- a/QuickLocation/Section/Schedule/ScheduleViewed/ScheduleViewedView.swift +++ b/QuickLocation/Section/Schedule/ScheduleViewed/ScheduleViewedView.swift @@ -73,7 +73,7 @@ class ScheduleViewedListCell: UITableViewCell { var disposeBag = DisposeBag() func configure(_ model: ViewedModel) { - iconView.image = model.userIcon + iconView.setHeadPic(model.displayHeadPic) nameLab.text = model.nick_name viewedCountLab.text = "看过我\(model.count)次" let groupNames = model.groups.map { $0.group_name } diff --git a/QuickLocation/Section/Schedule/ViewedModel.swift b/QuickLocation/Section/Schedule/ViewedModel.swift index 7bce48d0..d001c2b5 100644 --- a/QuickLocation/Section/Schedule/ViewedModel.swift +++ b/QuickLocation/Section/Schedule/ViewedModel.swift @@ -32,9 +32,13 @@ struct ViewedModel: Mappable, Equatable { var user_id: String = "" var nick_name: String = "" var head_pic: String = "" + var avater: String = "" + var displayHeadPic: String { + HeadPic.resolved(head_pic, avater) + } /// 头像 var userIcon: UIImage { - UIImage(named: "UserIcon/\(head_pic)") ?? UIImage() + HeadPic.image(for: displayHeadPic) } /// 查看次数 var count: Int = 0 @@ -49,7 +53,8 @@ struct ViewedModel: Mappable, Equatable { mutating func mapping(map: Map) { user_id <- map["user_id"] nick_name <- map["nick_name"] - head_pic <- map["head_pic"] + head_pic <- (map["head_pic"], kFlexString) + avater <- (map["avater"], kFlexString) count <- map["count"] groups <- map["groups"] } diff --git a/QuickLocation/Service/GroupService.swift b/QuickLocation/Service/GroupService.swift index 20c33825..abefbf5c 100644 --- a/QuickLocation/Service/GroupService.swift +++ b/QuickLocation/Service/GroupService.swift @@ -16,6 +16,12 @@ struct GroupService { let api = GroupAPI.groupInfo.multiTarget return APIProvider.request(token: api) .map(UserGroupResponse.self) + .map { response in + if let key = response.model?.default_group_key, !key.isEmpty { + AppContextManager.shared.defaultGroupKey = key + } + return response + } .asObservable() } @@ -43,6 +49,14 @@ struct GroupService { let api = GroupAPI.operate(opType: opType, requestData: requestData).multiTarget return APIProvider.request(token: api) .map(ResponseModel.self) + .map { response in + if opType == "setdefault", + let key = requestData["group_key"] as? String, + !key.isEmpty { + AppContextManager.shared.defaultGroupKey = key + } + return response + } .asObservable() } diff --git a/QuickLocation/Service/UserService.swift b/QuickLocation/Service/UserService.swift index 9031c484..01532f46 100644 --- a/QuickLocation/Service/UserService.swift +++ b/QuickLocation/Service/UserService.swift @@ -10,7 +10,7 @@ import Moya struct UserService { static let disposeBag = DisposeBag() - + /// 登录 /// - Parameters: /// - type: weixin、phone、apple、onekey、device、alipay @@ -25,7 +25,7 @@ struct UserService { .map(UserLoginRespons.self) .asObservable() } - + /// 用户信息 static func userInfo() -> Observable { let api = UserAPI.userInfo.multiTarget @@ -69,6 +69,128 @@ struct UserService { .map(PhoneUsageReportResponse.self) .asObservable() } + + /// 指定成员某日历史行程 + static func phoneUsageTrips(userId: String, date: Int64) -> Observable { + let api = UserAPI.phoneUsageTrips(userId: userId, date: date).multiTarget + return APIProvider.request(token: api, handle: false) + .map(PhoneUsageTripsResponse.self) + .asObservable() + } + + /// 查询应用图标 id(不存在时 fileId 为空) + static func queryPhoneUsageIcon(package: String) -> Observable { + let api = UserAPI.queryPhoneUsageIcon(package: package).multiTarget + return APIProvider.request(token: api, handle: false) + .map(PhoneUsageIconResponse.self) + .asObservable() + } + + /// 登记应用图标 + static func savePhoneUsageIcon(package: String, icon: String) -> Observable { + let api = UserAPI.savePhoneUsageIcon(package: package, icon: icon).multiTarget + return APIProvider.request(token: api, handle: false) + .map(PhoneUsageIconResponse.self) + .asObservable() + } + + /// 上报本机配对应用 + static func phoneLockApp(token: String, icon: String) -> Observable { + let api = UserAPI.phoneLockApp(token: token, icon: icon).multiTarget + return APIProvider.request(token: api, handle: false) + .map(ResponseModel.self) + .asObservable() + } + + /// 成员可锁应用列表 + static func phoneLockApps(userId: String, groupKey: String) -> Observable { + let api = UserAPI.phoneLockApps(userId: userId, groupKey: groupKey).multiTarget + return APIProvider.request(token: api, handle: false) + .map(PhoneLockAppsResponse.self) + .asObservable() + } + + /// 删除本机配对应用;空 token 数组表示全部删除 + static func phoneLockAppsDelete( + os: String = "ios", + tokens: [String] + ) -> Observable { + let api = UserAPI.phoneLockAppsDelete(os: os, tokens: tokens).multiTarget + return APIProvider.request(token: api, handle: false) + .map(ResponseModel.self) + .asObservable() + } + + /// 锁定成员应用 + static func phoneLock( + os: String, + groupKey: String, + userId: String, + tokens: [String], + iconIndex: Int, + message: String + ) -> Observable { + let api = UserAPI.phoneLock( + os: os, + groupKey: groupKey, + userId: userId, + tokens: tokens, + iconIndex: iconIndex, + message: message + ).multiTarget + return APIProvider.request(token: api, handle: false) + .map(ResponseModel.self) + .asObservable() + } + + /// 查询自己是否被锁 + static func phoneLocked(os: String = "ios") -> Observable { + let api = UserAPI.phoneLocked(os: os).multiTarget + return APIProvider.request(token: api, handle: false) + .map(PhoneLockedResponse.self) + .asObservable() + } + + /// 发起解锁请求 + static func requestPhoneUnlock( + os: String, + groupKey: String, + tokens: [String] + ) -> Observable { + let api = UserAPI.requestPhoneUnlock(os: os, groupKey: groupKey, tokens: tokens).multiTarget + return APIProvider.request(token: api, handle: false) + .map(ResponseModel.self) + .asObservable() + } + + /// 拉取解锁请求列表 + static func phoneUnlockRequests(os: String = "ios") -> Observable { + let api = UserAPI.phoneUnlockRequests(os: os).multiTarget + return APIProvider.request(token: api, handle: false) + .map(PhoneUnlockRequestsResponse.self) + .asObservable() + } + + /// 解锁 + static func phoneUnlock( + os: String, + groupKey: String, + userId: String, + tokens: [String] + ) -> Observable { + let api = UserAPI.phoneUnlock(os: os, groupKey: groupKey, userId: userId, tokens: tokens).multiTarget + return APIProvider.request(token: api, handle: false) + .map(ResponseModel.self) + .asObservable() + } + + /// 查询本机离线期间是否允许解锁 + static func phoneUnlockAllow(os: String = "ios") -> Observable { + let api = UserAPI.phoneUnlockAllow(os: os).multiTarget + return APIProvider.request(token: api, handle: false) + .map(PhoneUnlockAllowResponse.self) + .asObservable() + } static func imToken() -> Observable { let api = UserAPI.imToken.multiTarget @@ -93,9 +215,16 @@ struct UserService { .asObservable() } - /// 设置头像 - static func setHeadPic(index: Int) -> Observable { - let api = UserAPI.setHeadPic(index: index).multiTarget + /// 设置头像(预设序号或上传后的图片 URL) + static func setHeadPic(headPic: String) -> Observable { + let api = UserAPI.setHeadPic(headPic: headPic).multiTarget + return APIProvider.request(token: api) + .map(ResponseModel.self) + .asObservable() + } + + static func changeAvater(url: String) -> Observable { + let api = UserAPI.changeAvater(url: url).multiTarget return APIProvider.request(token: api) .map(ResponseModel.self) .asObservable() diff --git a/QuickLocation/zh-Hans.lproj/LaunchScreen.storyboard b/QuickLocation/zh-Hans.lproj/LaunchScreen.storyboard index 65dc86f5..5bc993cd 100644 --- a/QuickLocation/zh-Hans.lproj/LaunchScreen.storyboard +++ b/QuickLocation/zh-Hans.lproj/LaunchScreen.storyboard @@ -17,14 +17,14 @@ - + - + - + @@ -39,7 +39,7 @@ - - + + diff --git a/ShieldConfigurationExtension/Info.plist b/ShieldConfigurationExtension/Info.plist index cb5feed2..a1d73a72 100644 --- a/ShieldConfigurationExtension/Info.plist +++ b/ShieldConfigurationExtension/Info.plist @@ -16,6 +16,8 @@ 1.0 CFBundleVersion 1 + UIUserInterfaceStyle + Dark NSExtension NSExtensionPointIdentifier diff --git a/ShieldConfigurationExtension/ShieldConfigurationExtension.swift b/ShieldConfigurationExtension/ShieldConfigurationExtension.swift index 5adea059..25f19673 100644 --- a/ShieldConfigurationExtension/ShieldConfigurationExtension.swift +++ b/ShieldConfigurationExtension/ShieldConfigurationExtension.swift @@ -21,16 +21,15 @@ final class ShieldConfigurationExtension: ShieldConfigurationDataSource { private func makeConfiguration() -> ShieldConfiguration { let config = AppRestrictSharedStore.shieldConfig - let image = AppRestrictSharedStore.loadShieldImage() - let background = UIColor(red: 0.98, green: 0.98, blue: 0.98, alpha: 1) - let titleColor = UIColor(red: 0.16, green: 0.20, blue: 0.27, alpha: 1) - let bodyColor = UIColor(red: 0.46, green: 0.46, blue: 0.46, alpha: 1) + let image = AppRestrictSharedStore.loadShieldDisplayImage() + let titleColor = UIColor.white + let bodyColor = UIColor.white return ShieldConfiguration( - backgroundBlurStyle: .systemMaterial, - backgroundColor: background, + backgroundBlurStyle: .systemMaterialDark, + backgroundColor: .black, icon: image, title: ShieldConfiguration.Label(text: config.title, color: titleColor), - subtitle: ShieldConfiguration.Label(text: config.subtitle, color: bodyColor), + subtitle: ShieldConfiguration.Label(text: "\n\(config.subtitle)", color: bodyColor), primaryButtonLabel: ShieldConfiguration.Label(text: config.primaryButtonLabel, color: .white), primaryButtonBackgroundColor: UIColor(red: 0.09, green: 0.70, blue: 1.0, alpha: 1) )