// // HomeViewController.swift // QuickLocation // // Created by 八条 on 2026/5/27. // import UIKit import RxSwift import RxCocoa import RxDataSources import CoreLocation import AVFoundation import AudioToolbox import SwiftyUserDefaults #if !targetEnvironment(simulator) import AMapNaviKit import CocoaMQTT import Lottie import MarqueeLabel import ObjectMapper #endif class HomeViewController: BaseViewController { override var isNavigationBarHidden: Bool { true } override var preferredStatusBarStyle: UIStatusBarStyle { .default } // MARK: - Properties fileprivate var rootView: HomeView2! private var memberCV: UICollectionView { rootView.groupMemberView.memberCV } private var viewModel = HomeViewModel() /// 当前选中的成员(用于 GroupMemberListCell 选中态) private var selectedMemberId: String = "" private var inboxMessages: [PigeonSentMessage] = [] private var unlockRequestItems: [PhoneUnlockRequestItem] = [] private var phoneUsageCache: [String: PhoneUsageTodayModel] = [:] private var phoneUsageRequestIDs: [String: UUID] = [:] private let locationManager = CLLocationManager() private var currentHeading: Double = 0 private var members: [CircleMember] = [] private var currentUserMember: CircleMember? private var currentUserAnnotation: MemberAnnotation? #if !targetEnvironment(simulator) /// 自己持有的地图点,避免只靠 mapView.annotations 对不上同一实例 private var annotationByUserId: [String: MemberAnnotation] = [:] /// 散开前的真实坐标,annotation.coordinate 可能是展示偏移 private var trueCoordinateByUserId: [String: CLLocationCoordinate2D] = [:] private let overlapClusterRadius: CLLocationDistance = 20 private let overlapSpreadRadius: CLLocationDistance = 12 #endif /// 最新位置(供 MQTT 定时上报) private var lastLocation: CLLocation? private var locationTimer: Timer? /// 当前已订阅 MQTT 的成员 ID 列表 private var subscribedMemberIds: [String] = [] /// 底部成员面板是否显示 private var isMemberPanelShown = false /// 地图是否自动跟随当前用户;滑动查看后关闭,点击定位按钮后重新打开 private var isAutoFollowingUser = true private var isSOSPlaying = false private var sosReceiveAlertView: SOSReceiveAlertView? private var sosAudioStopWorkItem: DispatchWorkItem? private var sosVibrationTimer: DispatchSourceTimer? /// 记录每个成员最后一次收到位置的时间(MQTT userId → Date) private var lastUpdateTimes: [String: Date] = [:] /// 离线检测定时器 private var offlineCheckTimer: Timer? private var popupQueue: PopupQueueManager? private static var sosPlayerKey: UInt8 = 0 private var groupRefreshWorkItem: DispatchWorkItem? private var groupDataRetryWorkItem: DispatchWorkItem? private var groupInfoRequestID = UUID() private var pendingGroupDataChanges: [GroupDataChange] = [] private var hasAppeared = false private struct GroupDataChange { let groupKey: String let userId: String let operation: String let source: String init(notification: Notification) { groupKey = notification.userInfo?[GroupDataChangeUserInfoKey.groupKey] as? String ?? "" userId = notification.userInfo?[GroupDataChangeUserInfoKey.userId] as? String ?? "" operation = notification.userInfo?[GroupDataChangeUserInfoKey.operation] as? String ?? "" source = notification.userInfo?[GroupDataChangeUserInfoKey.source] as? String ?? "" } var isMQTT: Bool { source == GroupDataChangeSource.mqtt } } override func loadView() { #if !targetEnvironment(simulator) MAMapView.updatePrivacyAgree(.didAgree) MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain) #endif rootView = HomeView2(frame: UIScreen.main.bounds) view = rootView } override func viewDidLoad() { super.viewDidLoad() bindViewModel() setupMap() setupHeading() reactiveAction() // rootView.quickMessageView.tagListView.delegate = self // MQTT 位置上报 UIDevice.current.isBatteryMonitoringEnabled = true startLocationTimer() querySelfLocked() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) UnlockCountManager.shared.refreshAuthorizationAndMonitoring() MQTTService.shared.applicationDidBecomeActive() rootView.groupMemberView.refreshLocalDeviceInfoIfNeeded() let manager = AuthorizeManager.manager(type: .locationAlways) rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized refreshPigeonInbox() queryUnlockRequests() if hasAppeared { scheduleGroupDataRefresh() } else { hasAppeared = true } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) // sosPlayer?.stop() } // MARK: - MQTT 位置上报 private let geocoder = CLGeocoder() private var lastGeocodedCoord: CLLocationCoordinate2D? private var lastAddress: String = "" private func startLocationTimer() { locationTimer?.invalidate() let timer = Timer(timeInterval: 5, repeats: true) { [weak self] _ in self?.reportCurrentLocationIfNeeded() } RunLoop.main.add(timer, forMode: .common) locationTimer = timer reportCurrentLocationIfNeeded() } private func reportCurrentLocationIfNeeded() { guard !AppContextManager.shared.userId.isEmpty, let loc = lastLocation, CLLocationCoordinate2DIsValid(loc.coordinate) else { return } let coord = loc.coordinate publishLocation(coord: coord, address: lastAddress, loc: loc) refreshAddressIfNeeded(coord: coord, loc: loc) } private func refreshAddressIfNeeded(coord: CLLocationCoordinate2D, loc: CLLocation) { if let last = lastGeocodedCoord, hypot(coord.latitude - last.latitude, coord.longitude - last.longitude) < 0.001 { return } geocoder.cancelGeocode() geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in let address = self?.mqttAddress(from: placemarks?.first) ?? "" DispatchQueue.main.async { guard let self else { return } if !address.isEmpty { self.lastAddress = address } self.lastGeocodedCoord = coord } } } private func mqttAddress(from placemark: CLPlacemark?) -> 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( lat: coord.latitude, lon: coord.longitude, addr: address, speed: loc.speed, bearing: loc.course, altitude: loc.altitude, accuracy: loc.horizontalAccuracy ) } if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) } } // 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.makeUnlockRequestDisplayItems() guard !requests.isEmpty else { return } UnlockRequestPopView.show( requests: requests, onUnlock: { [weak self] item, done in self?.unlockMember(item, done: done) }, onReject: { _ in } ) } rootView.onUnlockReceiveTap = { [weak self] in guard let self else { return } let items = self.inboxMessages.map(self.makeReceiveDisplayItem) guard !items.isEmpty else { return } ReceiveMessagePopView.show( items: items, onRequestBurn: { [weak self] item, done in self?.burnInboxMessage(item, done: done) } ) } // 气泡 // rootView.bubbleView.rx.tapGesture.subscribe { _ in // AppRouter.push(Route.createBubble) // }.disposed(by: disposeBag) // 签到 // rootView.signInView.rx.tapGesture.subscribe { _ in // let vc = SignInVC(lastLocation: self.lastLocation) // vc.isNeedLogin = true // AppRouter.push(vc) // }.disposed(by: disposeBag) // SOS // rootView.sosView.rx.tapGesture.subscribe { _ in // let vc = SOSViewController() // vc.isNeedLogin = true // AppRouter.push(vc) // }.disposed(by: disposeBag) // 行程 // rootView.scheduleView.rx.tapGesture.subscribe { _ in // guard let model = self.viewModel.groupModel else { return } // AppRouter.push(Route.groupSchedule, userInfo: ["groupKey": model.default_group_key]) // }.disposed(by: disposeBag) // 切换圈子 rootView.groupMemberView.changeGroupBtn.rx.tap.subscribe(onNext: { [weak self] in self?.showLatestGroupPicker() }).disposed(by: disposeBag) // 顶部圈子 // rootView.groupView.rx.tapGesture.subscribe { _ in // guard let groupModel = self.viewModel.groupModel else { return } // let groupViewFrame = self.view.convert(self.rootView.groupView.frame, from: self.rootView) // let startPointY = groupViewFrame.origin.y + groupViewFrame.height // GroupListPopView.show(start: CGPoint(x: 0, y: startPointY + 20), // groupModel: groupModel) { groupKey in // guard let key = groupKey else { return } // self.requestOperateGroup(groupKey: key) // } // }.disposed(by: disposeBag) // 圈子成员列表 刷新列表 // rootView.groupMemberView.refreshBtn.rx.tap.subscribe(onNext: { _ in // self.requestGroupInfo() // }).disposed(by: disposeBag) // 邀请加入圈子 rootView.inviteBtn.rx.tap.subscribe(onNext: { _ in AppRouter.push(Route.inviteJoin, userInfo: ["groupInfo": self.viewModel.groupInfo]) }).disposed(by: disposeBag) rootView.groupMemberView.inviteBtn.rx.tap.subscribe(onNext: { _ in AppRouter.push(Route.inviteJoin, userInfo: ["groupInfo": self.viewModel.groupInfo]) }).disposed(by: disposeBag) // 今日轨迹二级页 rootView.groupMemberView.todayTrackView.rx.tapGesture.subscribe(onNext: { [weak self] _ in guard let self else { return } if self.rootView.groupMemberView.isTodayTrackVipRequired { AppRouter.push(Route.vipRecharge) } else { self.openTodayTrackDetail() } }).disposed(by: disposeBag) // 实时互动 rootView.groupMemberView.emojiCard.rx.tapGesture.subscribe(onNext: { [weak self] _ in self?.openEmojiPopView() }).disposed(by: disposeBag) rootView.groupMemberView.messageCard.rx.tapGesture.subscribe(onNext: { [weak self] _ in self?.openMessagePopView() }).disposed(by: disposeBag) // 手机报告二级页 rootView.groupMemberView.phoneReportView.rx.tapGesture.subscribe(onNext: { [weak self] _ in guard let self else { return } if self.rootView.groupMemberView.isPhoneReportVipRequired { AppRouter.push(Route.vipRecharge) return } guard !self.rootView.groupMemberView.isPhoneReportDenied else { return } self.openPhoneReportDetail() }).disposed(by: disposeBag) // APP 报告二级页 rootView.groupMemberView.appUsageView.rx.tapGesture.subscribe(onNext: { [weak self] _ in guard let self else { return } if self.rootView.groupMemberView.isAppUsageVipRequired { AppRouter.push(Route.vipRecharge) return } guard !self.rootView.groupMemberView.isAppUsageDenied else { return } self.openAppUsageReport() }).disposed(by: disposeBag) rootView.groupMemberView.onPhoneReportRemind = { [weak self] in self?.remindSelectedMember(content: "请开启手机报告隐私设置") } rootView.groupMemberView.onAppUsageRemind = { [weak self] in self?.remindSelectedMember(content: "请开启App使用记录隐私设置") } // 地图回到自己并重新开启自动跟随 rootView.locationView.rx.tapGesture.subscribe { _ in self.startFollowingCurrentUser() }.disposed(by: disposeBag) // User Config刷新 NotificationCenter.default.rx.notification(.RefreshUserConfigNotification, object: nil) .startWith(Notification(name: .RefreshUserConfigNotification)) .do(onNext: { [weak self] _ in self?.popupQueue = nil }) .flatMapLatest { _ in SystemService.userConfig() } .subscribe(onNext: { [weak self] response in guard let self = self, let model = response.model else { return } Defaults[\.loginToken] = model.token AppContextManager.shared.systemConfig = model.config self.rootView.groupMemberView.refreshInterceptState() // rootView.searchLottieView.isHidden = model.config?.isIntercept == true self.requestUserInfo { [weak self] in self?.requestGroupInfo() } self.requestNotice() self.refreshPigeonInbox() self.popupQueue = nil self.popupQueue = PopupQueueManager() self.popupQueue?.start(from: self) }).disposed(by: disposeBag) // 圈子刷新 NotificationCenter.default.rx.notification(.RefreshGroupInfoNotification, object: nil) .subscribe { [weak self] notification in self?.scheduleGroupDataRefresh(change: GroupDataChange(notification: notification)) }.disposed(by: disposeBag) // 查找位置(查看成员位置) NotificationCenter.default.rx.notification(.ShowMemberLocationNotification, object: nil) .subscribe { [weak self] notification in guard let self = self, let userInfo = notification.userInfo, let last_position = userInfo["last_position"] as? String, let group_key = userInfo["group_key"] as? String else { return } // 解析坐标 "lat:lng:address" let parts = last_position.components(separatedBy: ":") let coord: CLLocationCoordinate2D? if parts.count >= 2, let lat = Double(parts[0]), let lng = Double(parts[1]) { coord = CLLocationCoordinate2D(latitude: lat, longitude: lng) } else { coord = nil } // 切换到成员所在圈子,完成后定位 GroupService.operate(opType: "setdefault", requestData: ["group_key": group_key]).subscribe { _ in if let c = coord, CLLocationCoordinate2DIsValid(c) { self.rootView.mapView.setCenter(c, animated: true) self.rootView.mapView.setZoomLevel(16, animated: true) } }.disposed(by: self.disposeBag) }.disposed(by: disposeBag) // 面板关闭回调 // rootView.onDismissPanel = { [weak self] in // self?.isMemberPanelShown = false // (self?.tabBarController as? MainTabBarController)?.setTabBarHidden(false) // } // 导航按钮回调 // rootView.interactionView.onNavigate = { [weak self] in // guard let self = self, let member = self.rootView.interactionView.currentMember else { return } // self.rootView.dismissMemberPanel() // let userCoord = self.lastLocation?.coordinate // let groupName = self.viewModel.groupName // let model = self.viewModel.groupModel // let iconIndex = model?.groups.first(where: { $0.group_key == model?.default_group_key })?.icon_index ?? 1 // let vc = NavigationVC(member: member, currentUserCoord: userCoord, groupName: groupName, groupIcon: "\(iconIndex)") // self.navigationController?.pushViewController(vc, animated: true) // } // 发送表情 // rootView.interactionView.onSendEmote = { [weak self] emoteIdx in // guard let self = self else { return } // self.requestSendEmote(emoteIdx: emoteIdx) // } // 分享 // rootView.interactionView.onShare = { // SharePopView.show() // } } private func bindViewModel() { viewModel.output.sectionedItems .do(onNext: { [weak self] sections in guard let self = self else { return } let items = sections.flatMap(\.items) guard !items.isEmpty else { self.selectedMemberId = "" self.refreshCoupleMemberList([]) self.rootView.setSOSGradientActive(false) self.rootView.groupMemberView.clearMemberInfo() return } let stillValid = items.contains { $0.user_id == self.selectedMemberId } if self.selectedMemberId.isEmpty || !stillValid { if let me = items.first(where: { self.viewModel.isCurrentUser(id: $0.user_id) }) { self.selectedMemberId = me.user_id } else { self.selectedMemberId = items[0].user_id } } self.refreshCoupleMemberList(items) self.refreshSelectedMemberInfoIfNeeded(userId: self.selectedMemberId) self.updateSelectedMemberSOSGradient() }) .bind(to: memberCV.rx.items(dataSource: dataSource)) .disposed(by: disposeBag) let selectedMember = memberCV.rx.modelSelected(GroupMemberModel.self) .share() selectedMember .subscribe(viewModel.cellAction.inputs) .disposed(by: disposeBag) // 选中成员 → 高亮 + 地图定位 selectedMember .subscribe(onNext: { [weak self] model in self?.selectMember(userId: model.user_id, centerMap: true, dismissPanel: true) }) .disposed(by: disposeBag) } // MARK: - UICollectionViewDataSource lazy private var dataSource: RxCollectionViewSectionedReloadDataSource = { RxCollectionViewSectionedReloadDataSource { [weak self] _, collectionView, indexPath, model in let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath) cell.configure(model: model, isCurrentUser: self?.viewModel.isCurrentUser(id: model.user_id) ?? false, isSelected: model.user_id == self?.selectedMemberId) return cell } }() // MARK: - API 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 makeUnlockRequestDisplayItems() -> [UnlockRequestDisplayItem] { unlockRequestItems.map { item in let member = viewModel.memberList.first { member in member.user_id == item.targetUserId } return UnlockRequestDisplayItem( member: member, fallbackName: item.nickName, headPic: item.headPic, lockStartTime: item.lockStartDate, os: item.os, groupKey: item.groupKey, userId: item.targetUserId, tokens: item.tokens ) } } private func refreshPigeonInbox() { guard Defaults[\.loginToken] != nil else { inboxMessages = [] rootView.pigeonMsgBtn.isHidden = true return } PigeonService.inbox() .subscribe(onNext: { [weak self] response in guard let self else { return } self.inboxMessages = response.list self.rootView.pigeonMsgBtn.isHidden = response.list.isEmpty }, onError: { [weak self] _ in self?.rootView.pigeonMsgBtn.isHidden = self?.inboxMessages.isEmpty ?? true }) .disposed(by: disposeBag) } private func makeReceiveDisplayItem(_ message: PigeonSentMessage) -> ReceiveMessageDisplayItem { let localTemplateImage = message.localTemplateImage let senderId = message.from_user?.user_id ?? "" let memberRelationIdx = viewModel.memberList.first { $0.user_id == senderId }? .extra.relation_idx.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let relationIdx = memberRelationIdx.isEmpty ? message.from_user?.extra.relation_idx ?? "" : memberRelationIdx return ReceiveMessageDisplayItem( 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, kind: message.isVoice ? .voice : .image, duration: message.localVoiceTemplate?.duration ?? 0, localAudioData: message.localVoiceData, audioURL: message.audioURL, relationIdx: relationIdx ) } private func burnInboxMessage(_ item: ReceiveMessageDisplayItem, done: @escaping (Bool) -> Void) { guard !item.messageUUID.isEmpty else { done(true) return } DLToast.showLoading() PigeonService.burn(messageUUID: item.messageUUID) .subscribe(onNext: { [weak self] _ in DLToast.dismiss() guard let self else { done(true) return } self.inboxMessages.removeAll { $0.message_uuid == item.messageUUID } self.rootView.pigeonMsgBtn.isHidden = self.inboxMessages.isEmpty done(true) }, onError: { error in DLToast.dismiss() DLToast.show(error) done(false) }) .disposed(by: disposeBag) } private func requestUserInfo(completion: (() -> Void)? = nil) { UserService.userInfo().subscribe { response in guard let model = response.model else { return } AppContextManager.shared.saveAccount(model) // self.rootView.avatarImgView.image = model.userIcon completion?() }.disposed(by: disposeBag) } private func scheduleGroupDataRefresh(change: GroupDataChange? = nil) { if let change { pendingGroupDataChanges.append(change) } groupRefreshWorkItem?.cancel() let work = DispatchWorkItem { [weak self] in guard let self else { return } let changes = self.pendingGroupDataChanges self.pendingGroupDataChanges.removeAll() self.requestGroupInfo( changes: changes, retryAllowed: changes.contains(where: \.isMQTT), showError: false ) } groupRefreshWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work) } private func requestGroupInfo(changes: [GroupDataChange] = [], retryAllowed: Bool = false, showError: Bool = true) { let requestID = UUID() groupInfoRequestID = requestID GroupService.groupInfo().subscribe(onNext: { [weak self] response in guard let self, self.groupInfoRequestID == requestID else { return } guard let model = response.model else { if showError { DLToast.showError(text: response.message ?? "获取圈子信息失败") } if retryAllowed { self.scheduleGroupDataRetry(changes: changes) } return } self.applyGroupModel( model, requestID: requestID, changes: changes, retryAllowed: retryAllowed, showError: showError ) }, onError: { [weak self] error in guard let self, self.groupInfoRequestID == requestID else { return } if showError { DLToast.showError(error) } if retryAllowed { self.scheduleGroupDataRetry(changes: changes) } }).disposed(by: disposeBag) } private func applyGroupModel(_ model: GroupModel, requestID: UUID, changes: [GroupDataChange], retryAllowed: Bool, showError: Bool) { let previousGroupKey = viewModel.groupModel?.default_group_key ?? "" let requestedGroupKey = model.default_group_key let isSwitchingGroup = previousGroupKey != requestedGroupKey let hasRequestedGroup = model.groups.contains { $0.group_key == requestedGroupKey } viewModel.groupModel = model rootView.groupMemberView.groupNameLab.text = viewModel.groupName.isEmpty ? "我的圈子" : viewModel.groupName MQTTService.shared.updatePhoneReportContext( groupKey: hasRequestedGroup ? requestedGroupKey : "", location: phoneReportLocation ) let pickerMembers = isSwitchingGroup ? nil : viewModel.memberList GroupListPopView.updateIfVisible( groupModel: model, currentMembers: pickerMembers, presentationOwner: self ) if isSwitchingGroup || requestedGroupKey.isEmpty || !hasRequestedGroup { clearPhoneUsageState() viewModel.clearMembers() syncMemberAnnotations([]) refreshMQTTSubscriptions([]) } guard !requestedGroupKey.isEmpty, hasRequestedGroup else { if retryAllowed, shouldRetryGroupList(model: model, changes: changes) { scheduleGroupDataRetry(changes: changes) } return } let shouldRetryList = retryAllowed && shouldRetryGroupList(model: model, changes: changes) requestGroupMembers( groupKey: requestedGroupKey, requestID: requestID, changes: changes, retryAllowed: retryAllowed && !shouldRetryList, showError: showError ) if shouldRetryList { scheduleGroupDataRetry(changes: changes) } } private func shouldRetryGroupList(model: GroupModel, changes: [GroupDataChange]) -> Bool { let currentUserId = AppContextManager.shared.userId let groupKeys = Set(model.groups.map(\.group_key)) return changes.contains { change in guard change.isMQTT, !change.groupKey.isEmpty else { return false } switch change.operation { case "join": return (change.userId.isEmpty || change.userId == currentUserId) && !groupKeys.contains(change.groupKey) case "leave", "kick": return change.userId == currentUserId && groupKeys.contains(change.groupKey) case "dismiss": return groupKeys.contains(change.groupKey) default: return false } } } private func shouldRetryMembers(_ members: [GroupMemberModel], groupKey: String, changes: [GroupDataChange]) -> Bool { let memberIds = Set(members.map(\.user_id)) return changes.contains { change in guard change.isMQTT, change.groupKey == groupKey, !change.userId.isEmpty else { return false } switch change.operation { case "join": return !memberIds.contains(change.userId) case "leave", "kick": return memberIds.contains(change.userId) default: return false } } } private func scheduleGroupDataRetry(changes: [GroupDataChange]) { groupDataRetryWorkItem?.cancel() let work = DispatchWorkItem { [weak self] in self?.requestGroupInfo(changes: changes, retryAllowed: false, showError: false) } groupDataRetryWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: work) } private func showLatestGroupPicker() { groupDataRetryWorkItem?.cancel() let cachedModel = viewModel.groupModel let requestID = UUID() groupInfoRequestID = requestID GroupService.groupInfo() .subscribe(onNext: { [weak self] response in guard let self, self.groupInfoRequestID == requestID else { return } guard let model = response.model else { if let cachedModel { self.presentGroupPicker(groupModel: cachedModel) } return } self.applyGroupModel( model, requestID: requestID, changes: [], retryAllowed: false, showError: false ) self.presentGroupPicker(groupModel: model) }, onError: { [weak self] _ in guard let self, self.groupInfoRequestID == requestID, let cachedModel else { return } self.presentGroupPicker(groupModel: cachedModel) }) .disposed(by: disposeBag) } private func presentGroupPicker(groupModel: GroupModel) { GroupListPopView.show( groupModel: groupModel, currentMembers: viewModel.memberList, presentationOwner: self ) { [weak self] groupKey in guard let self, let groupKey else { return } self.requestOperateGroup(groupKey: groupKey) } } private func requestGroupMembers(groupKey: String, requestID: UUID, changes: [GroupDataChange] = [], retryAllowed: Bool = false, showError: Bool = true) { GroupService.groupUsers(groupKey: groupKey) .subscribe(onNext: { [weak self] response in guard let self, self.groupInfoRequestID == requestID, self.viewModel.groupModel?.default_group_key == groupKey else { return } guard response.isValid(for: groupKey) else { if showError { DLToast.showError(text: response.message ?? "获取圈子成员失败") } if retryAllowed { self.scheduleGroupDataRetry(changes: changes) } return } self.viewModel.updateGroupUsers(response.list, vip: response.vip) // 成员接口是气泡结束的权威来源:status=0 时重新显示地图点。 self.syncMemberAnnotations(self.viewModel.memberList) self.refreshMQTTSubscriptions(self.viewModel.memberList) self.updateOnlineCount() self.requestSelectedMemberPhoneUsage(groupKey: groupKey) if let groupModel = self.viewModel.groupModel { GroupListPopView.updateIfVisible( groupModel: groupModel, currentMembers: self.viewModel.memberList, presentationOwner: self ) } if retryAllowed, self.shouldRetryMembers(response.list, groupKey: groupKey, changes: changes) { self.scheduleGroupDataRetry(changes: changes) } }, onError: { [weak self] error in guard let self, self.groupInfoRequestID == requestID, self.viewModel.groupModel?.default_group_key == groupKey else { return } if showError { DLToast.showError(error) } if retryAllowed { self.scheduleGroupDataRetry(changes: changes) } }) .disposed(by: disposeBag) } private var phoneReportLocation: CLLocation? { if let lastLocation, CLLocationCoordinate2DIsValid(lastLocation.coordinate) { return lastLocation } guard let latitude = Defaults[\.currentLatitude], let longitude = Defaults[\.currentLongitude] else { return nil } let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) guard CLLocationCoordinate2DIsValid(coordinate) else { return nil } return CLLocation(latitude: latitude, longitude: longitude) } private func phoneUsageCacheKey(groupKey: String, userId: String) -> String { "\(groupKey)|\(userId)" } private func clearPhoneUsageState() { phoneUsageCache.removeAll() phoneUsageRequestIDs.removeAll() if !selectedMemberId.isEmpty { rootView.groupMemberView.updatePhoneUsage(nil, for: selectedMemberId) } } private func requestSelectedMemberPhoneUsage(groupKey: String? = nil) { let resolvedGroupKey = groupKey ?? viewModel.groupModel?.default_group_key ?? "" requestPhoneUsage(userId: selectedMemberId, groupKey: resolvedGroupKey) } private func requestPhoneUsage(userId: String, groupKey: String) { guard !userId.isEmpty, !groupKey.isEmpty else { return } let cacheKey = phoneUsageCacheKey(groupKey: groupKey, userId: userId) if let cached = phoneUsageCache[cacheKey], selectedMemberId == userId { rootView.groupMemberView.updatePhoneUsage(cached, for: userId) } guard phoneUsageRequestIDs[cacheKey] == nil else { return } let requestID = UUID() phoneUsageRequestIDs[cacheKey] = requestID UserService.phoneUsageToday(userId: userId, groupKey: groupKey) .subscribe(onNext: { [weak self] response in guard let self, self.phoneUsageRequestIDs[cacheKey] == requestID else { return } self.phoneUsageRequestIDs.removeValue(forKey: cacheKey) guard self.viewModel.groupModel?.default_group_key == groupKey else { return } guard response.code == "0", let model = response.model else { self.phoneUsageCache.removeValue(forKey: cacheKey) if self.selectedMemberId == userId { self.rootView.groupMemberView.updatePhoneUsage(nil, for: userId) } return } self.phoneUsageCache[cacheKey] = model guard self.selectedMemberId == userId else { return } self.rootView.groupMemberView.updatePhoneUsage(model, for: userId) }, onError: { [weak self] _ in guard let self, self.phoneUsageRequestIDs[cacheKey] == requestID else { return } self.phoneUsageRequestIDs.removeValue(forKey: cacheKey) guard self.viewModel.groupModel?.default_group_key == groupKey else { return } self.phoneUsageCache.removeValue(forKey: cacheKey) if self.selectedMemberId == userId { self.rootView.groupMemberView.updatePhoneUsage(nil, for: userId) } }) .disposed(by: disposeBag) } private func requestOperateGroup(groupKey: String) { groupDataRetryWorkItem?.cancel() if viewModel.groupModel?.default_group_key != groupKey { groupInfoRequestID = UUID() clearPhoneUsageState() viewModel.clearMembers() syncMemberAnnotations([]) refreshMQTTSubscriptions([]) } GroupService.operate(opType: "setdefault", requestData: ["group_key" : groupKey]) .subscribe(onNext: { _ in }, onError: { error in DLToast.showError(error) }) .disposed(by: disposeBag) } private func requestNotice() { // UserService.notice().subscribe { response in // self.rootView.noticeLab.text = "" // guard let data = response.data, let noticeList = data["notice"] as? [String] else { return } // self.rootView.noticeView.isHidden = noticeList.count == 0 // self.rootView.noticeLab.text = noticeList.joined(separator: " ") + " " // if let loop = data["loop"] as? Bool { // self.rootView.noticeLab.type = loop ? .continuous : .leftRight // self.rootView.noticeLab.onScrollLoopComplete = { // guard loop == false else { return } // self.rootView.noticeView.isHidden = true // } // } // }.disposed(by: disposeBag) } // MARK: - Map Setup private func setupMap() { #if !targetEnvironment(simulator) rootView.mapView.delegate = self // 地图点击选择标注 let mapTap = UITapGestureRecognizer(target: self, action: #selector(handleMapTap(_:))) mapTap.cancelsTouchesInView = false rootView.mapView.addGestureRecognizer(mapTap) let r = MAUserLocationRepresentation() r.showsAccuracyRing = false r.showsHeadingIndicator = false r.enablePulseAnnimation = false r.lineWidth = 0 r.image = transparentImage() rootView.mapView.update(r) rootView.mapView.showsUserLocation = true rootView.mapView.userTrackingMode = .none #endif } @objc private func handleMapTap(_ tap: UITapGestureRecognizer) { let point = tap.location(in: rootView.mapView) // 色点对准坐标,气泡在上方,命中区需向上覆盖整颗标注 let hitWidth: CGFloat = 70 let hitHeight: CGFloat = 110 for ann in rootView.mapView.annotations?.compactMap({ $0 as? MemberAnnotation }) ?? [] { let pt = rootView.mapView.convert(ann.coordinate, toPointTo: rootView.mapView) let hitRect = CGRect( x: pt.x - hitWidth / 2, y: pt.y - hitHeight + 12, width: hitWidth, height: hitHeight ) if hitRect.contains(point) { selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false) break } } } private func transparentImage() -> UIImage { UIGraphicsBeginImageContextWithOptions(CGSize(width: 1, height: 1), false, 0) let img = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage() UIGraphicsEndImageContext() return img } // MARK: - Heading (CLLocationManager) private func setupHeading() { locationManager.delegate = self locationManager.startUpdatingHeading() } private func updateCurrentUserHeading() { #if !targetEnvironment(simulator) guard let ann = currentUserAnnotation, let view = rootView.mapView.view(for: ann) as? MemberAnnotationView else { return } view.updateHeading(currentHeading) #endif } // MARK: - Map Annotations from API data private func syncMemberAnnotations(_ list: [GroupMemberModel]) { #if !targetEnvironment(simulator) let isGroupOwner: (String) -> Bool = { [weak self] id in self?.viewModel.isGroupOwn(id: id) ?? false } let currentUserId = AppContextManager.shared.userId let currentModel = list.first(where: { $0.user_id == currentUserId }) let lastUpdateTime = currentModel?.last_active_time ?? 0 // 当前用户始终由 GPS 定位,单独注入;头像与成员列表同一份数据 let me = CircleMember( id: "current", name: currentModel?.showName ?? AppContextManager.shared.name, avatar: currentModel?.displayHeadPic ?? AppContextManager.shared.head_pic, isOnline: true, isOwner: currentModel.map { isGroupOwner($0.user_id) } ?? false, coordinate: kCLLocationCoordinate2DInvalid, address: "", heading: 0, lastUpdateTime: lastUpdateTime, battery: currentModel?.battery ?? "" ) // 其他成员来自当前圈子的成员接口,过滤掉当前用户。 var others: [CircleMember] = [] let now = Date() for model in list where model.user_id != currentUserId || currentUserId.isEmpty { var m = CircleMember(member: model, isOwner: isGroupOwner(model.user_id)) let mqttFresh = lastUpdateTimes[model.user_id].map { now.timeIntervalSince($0) < 60 } ?? false if mqttFresh, let trueCoord = trueCoordinateByUserId[model.user_id], CLLocationCoordinate2DIsValid(trueCoord) { m = CircleMember( id: m.id, name: m.name, avatar: m.avatar, isOnline: true, isOwner: m.isOwner, coordinate: trueCoord, address: m.address, heading: m.heading, lastUpdateTime: m.lastUpdateTime, battery: m.battery ) } guard CLLocationCoordinate2DIsValid(m.coordinate) else { continue } others.append(m) } let newMembers = others + [me] members = newMembers currentUserMember = me refreshCurrentUserAnnotationAvatar(me) // 从 members 中过滤出在线成员用于地图标注(当前用户坐标无效,由 GPS 回调单独添加) let onlineMembers = newMembers.filter { $0.isOnline && !$0.isCurrentUser && !isMemberInBubble($0.id) } guard let mapView = rootView.mapView else { return } let existing = mapView.annotations?.compactMap { $0 as? MemberAnnotation } ?? [] var seen = Set() var allExisting: [MemberAnnotation] = [] for annotation in Array(annotationByUserId.values) + existing { let key = ObjectIdentifier(annotation) if seen.insert(key).inserted { allExisting.append(annotation) } } let onlineIDs = Set(onlineMembers.map { $0.id }) let toRemove = allExisting.filter { annotation in if annotation === currentUserAnnotation || annotation.member.isCurrentUser || annotation.member.id == "current" { return false } return !onlineIDs.contains(annotation.member.id) } if !toRemove.isEmpty { mapView.removeAnnotations(toRemove) } for annotation in toRemove { annotationByUserId.removeValue(forKey: annotation.member.id) if annotation === currentUserAnnotation || annotation.member.isCurrentUser { currentUserAnnotation = nil } } for id in Array(annotationByUserId.keys) where !onlineIDs.contains(id) { annotationByUserId.removeValue(forKey: id) } let toAdd = onlineMembers.filter { annotationByUserId[$0.id] == nil } let annotations = toAdd.map { MemberAnnotation(member: $0) } if !annotations.isEmpty { mapView.addAnnotations(annotations) annotations.forEach { rememberAnnotation($0) } } // 自己的针只等高德 didUpdate 再加,不用苹果 GPS / 缓存点 applyDisplayCoordinates() refreshAnnotationScreenPositions() bringAnnotationToFront(userId: selectedMemberId) #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 remindSelectedMember(content: String) { GroupService.sendPrivilegeRemind( content: content, groupKey: viewModel.groupModel?.default_group_key ?? "", userId: selectedMemberId ) } /// 若更新的是当前选中成员,刷新信息卡(位置 / 在线 / 距离等) private func refreshSelectedMemberInfoIfNeeded(userId: String) { guard userId == selectedMemberId, let model = viewModel.memberList.first(where: { $0.user_id == userId }) else { return } let groupKey = viewModel.groupModel?.default_group_key ?? "" let usage = phoneUsageCache[phoneUsageCacheKey(groupKey: groupKey, userId: userId)] rootView.groupMemberView.setupMemberInfo( model, isOwner: viewModel.isGroupOwn(id: model.user_id), phoneUsage: usage, groupVip: viewModel.groupVip ) } private func updateSelectedMemberSOSGradient() { let isActive = viewModel.memberList.first(where: { $0.user_id == selectedMemberId })?.status == 1 rootView.setSOSGradientActive(isActive) } private func openTodayTrackDetail() { let members = viewModel.memberList.map { $0.toJSON() } let userInfo: [String: Any] = [ "members": members, "userId": selectedMemberId ] AppRouter.push(Route.todayTrackDetail, userInfo: userInfo) } private func openEmojiPopView() { AddEmojiPopView.show { [weak self] name in guard let self, let name else { return } self.sendRealtimeEmoji(name) } } private func openMessagePopView() { AddMessagePopView.show { [weak self] index in guard let self, let index else { return } self.sendRealtimeMessage(index: index) } } private func sendRealtimeEmoji(_ name: String) { guard name.hasPrefix("normal_") else { return } let numStr = name.replacingOccurrences(of: "normal_", with: "") guard let num = Int(numStr) else { return } let targetId = selectedMemberId sendRealtimeEmote(emoteIdx: "1\(num)".integer) { [weak self] in self?.rootView.groupMemberView.applyEmojiEcho(name: name, for: targetId) } } private func sendRealtimeMessage(index: Int) { let texts = QuickMessageView.messageList guard texts.indices.contains(index) else { return } let targetId = selectedMemberId sendRealtimeEmote(emoteIdx: "3\(index)".integer) { [weak self] in self?.rootView.groupMemberView.applyMessageEcho(text: texts[index], for: targetId) } } private func sendRealtimeEmote(emoteIdx: Int, onSuccess: @escaping () -> Void) { guard selectedMemberId != AppContextManager.shared.userId else { DLToast.show(text: "不能给自己发送") return } guard let model = viewModel.groupModel else { return } DLToast.showLoading() UserService.sendEmote( emoteIdx: emoteIdx, groupKey: model.default_group_key, targetUid: selectedMemberId ).subscribe(onNext: { _ in DLToast.show(text: "发送成功") onSuccess() }, onError: { _ in }).disposed(by: disposeBag) } private func applyIncomingEmote(from userId: String, emojiName: String?, message: String?) { if let emojiName { rootView.groupMemberView.applyEmojiEcho(name: emojiName, for: userId) ReceivedEmojiPopView.show(name: emojiName) } if let message { rootView.groupMemberView.applyMessageEcho(text: message, for: userId) ReceivedMessagePopView.showIncoming( nickName: viewModel.getUserNickName(id: userId), message: message ) } } private func openPhoneReportDetail() { let members = viewModel.memberList.map { $0.toJSON() } var userInfo: [String: Any] = [ "members": members, "userId": selectedMemberId ] if let group = viewModel.groupModel { userInfo["groupJson"] = group.toJSON() } AppRouter.push(Route.phoneReportDetail, userInfo: userInfo) } private func openAppUsageReport() { let members = viewModel.memberList.map { $0.toJSON() } var userInfo: [String: Any] = [ "members": members, "userId": selectedMemberId ] if let group = viewModel.groupModel { userInfo["groupJson"] = group.toJSON() } AppRouter.push(Route.appUsageReport, userInfo: userInfo) } /// 选中成员:同步列表高亮 / 信息卡,并可选居中地图、收起面板 private func selectMember(userId: String, centerMap: Bool = true, dismissPanel: Bool = false) { let listUserId = (userId == "current") ? AppContextManager.shared.userId : userId guard !listUserId.isEmpty else { return } selectedMemberId = listUserId updateSelectedMemberSOSGradient() 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) DispatchQueue.main.async { [weak self] in guard let self = self, self.memberCV.numberOfSections > 0, self.memberCV.numberOfItems(inSection: 0) > idx else { return } self.memberCV.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) } } // if dismissPanel { // rootView.dismissGroupMemberView() // } if centerMap { let isSelf = userId == "current" || viewModel.isCurrentUser(id: userId) if !isSelf { stopFollowingCurrentUser() } centerMapOnMember(userId: userId) } #if !targetEnvironment(simulator) bringAnnotationToFront(userId: listUserId, restack: true) #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) if let ann = currentUserAnnotation { selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false) } else if let coordinate = lastLocation?.coordinate, CLLocationCoordinate2DIsValid(coordinate) { rootView.mapView.setCenter(coordinate, animated: true) rootView.mapView.setZoomLevel(16, animated: true) } rootView.mapView.setUserTrackingMode(.follow, animated: true) #endif } private func stopFollowingCurrentUser() { guard isAutoFollowingUser else { return } isAutoFollowingUser = false #if !targetEnvironment(simulator) rootView.mapView.setUserTrackingMode(.none, animated: false) #endif } #if !targetEnvironment(simulator) /// 地图居中到成员(当前用户 id 为 "current" 或真实 userId) private func centerMapOnMember(userId: String) { let coordinate: CLLocationCoordinate2D? if userId == "current" || viewModel.isCurrentUser(id: userId) { coordinate = currentUserAnnotation?.coordinate ?? lastLocation?.coordinate } else { coordinate = members.first(where: { $0.id == userId })?.coordinate } guard let coord = coordinate, CLLocationCoordinate2DIsValid(coord) else { return } rootView.mapView.setCenter(coord, animated: true) rootView.mapView.setZoomLevel(16, animated: true) } /// 选中成员后地图定位到该成员,收起 GroupMemberView private func locateMember(userId: String) { selectMember(userId: userId, centerMap: true, dismissPanel: true) } #endif } // MARK: - MQTT extension HomeViewController { /// 切换圈子时刷新 MQTT 订阅 private func refreshMQTTSubscriptions(_ members: [GroupMemberModel]) { // let currentId = AppContextManager.shared.userId let newIds = members.map { $0.user_id }//.filter { $0 != currentId } // 排除当前用户 let toUnsub = subscribedMemberIds.filter { !newIds.contains($0) } MQTTService.shared.unsubscribeGroupMembers(toUnsub) let toSub = newIds.filter { !subscribedMemberIds.contains($0) } for memberId in toSub { let topic = "smartdrive/\(memberId)" MQTTService.shared.subscribe(topic: topic) { [weak self] message in self?.handleMemberLocation(topic: message.topic, payload: message.string) } } 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(error, fallbackText: "解锁失败") 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")") guard let payload = payload, let data = payload.data(using: .utf8), let msg = try? JSONDecoder().decode(MqttIncomingMessage.self, from: data) else { return } let userId = topic.replacingOccurrences(of: "smartdrive/", with: "") switch msg.type { case "track": guard let firstPoint = msg.data?.points?.first else { return } let coord = CLLocationCoordinate2D(latitude: firstPoint.lat, longitude: firstPoint.lon) guard CLLocationCoordinate2DIsValid(coord) else { return } // 根据 time 字段判断在线状态 let nowMs = Date().timeIntervalSince1970 * 1000 let msgTimeMs = Double(firstPoint.time) let diffSec = (nowMs - msgTimeMs) / 1000 let isOnline = diffSec < 60 let battery = msg.data?.battery ?? "" DispatchQueue.main.async { [weak self] in guard let self = self else { return } // 记录更新时间 self.lastUpdateTimes[userId] = Date() // 更新列表(地址、电量、在线状态) self.viewModel.updateMemberLocation( userId: userId, lat: firstPoint.lat, lon: firstPoint.lon, address: firstPoint.addr, battery: battery, isOnline: isOnline, lastUpdateTime: firstPoint.time ) updateOnlineCount() self.refreshSelectedMemberInfoIfNeeded(userId: userId) // 自己的地图点由 GPS 维护(id = "current"),MQTT 回环不再加点,避免叠两个自己 if userId == AppContextManager.shared.userId { return } // 经纬度无变化跳过地图标注更新 let shouldUpdateMap: Bool = { if let existing = self.members.first(where: { $0.id == userId }) { return abs(existing.coordinate.latitude - coord.latitude) > 0.00001 || abs(existing.coordinate.longitude - coord.longitude) > 0.00001 } return true }() if self.isMemberInBubble(userId) { self.removeAnnotation(userId: userId) } else if isOnline, shouldUpdateMap { self.updateAnnotation(userId: userId, coordinate: coord, address: firstPoint.addr) } else if !isOnline { self.removeAnnotation(userId: userId) } } case "disconnect": // 离线 self.removeAnnotation(userId: userId) viewModel.setMemberOffline(userId: userId) 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 guard isSelf else { return } AppUnlockCoordinator.shared.registerIncomingLock(tokens: record.tokens) self?.applyIncomingLocks([record]) } case "emote": // 快捷消息、表情 guard let targetUserId = msg.data?.user_id, targetUserId == AppContextManager.shared.userId, // 只接收发给我的 let index = msg.data?.index, index > 9, let groupKey = msg.data?.group_key, groupKey.components(separatedBy: "/").count >= 2 else { break } let firstChar = index.string.prefix(1) let lastChar = String(index.string.suffix(index.string.count-1)) let emoteUserId = userId if firstChar == "1" || firstChar == "2" { // 表情 let emojiFileName = firstChar == "1" ? "normal_\(lastChar)" : "fun_\(lastChar)" DispatchQueue.main.async { [weak self] in self?.applyIncomingEmote(from: emoteUserId, emojiName: emojiFileName, message: nil) } } else if firstChar == "3" { // 文字 let textIdx = lastChar.integer let texts = QuickMessageView.messageList guard textIdx < texts.count else { break } DispatchQueue.main.async { [weak self] in self?.applyIncomingEmote(from: emoteUserId, emojiName: nil, message: texts[textIdx]) } } case "bubble": // 气泡 guard let userId = msg.data?.user_id else { return } DispatchQueue.main.async { [weak self] in self?.viewModel.markMemberInBubble(userId: userId) self?.removeAnnotation(userId: userId) } case "bubblequit": let targetId = msg.data?.user_id ?? userId guard !targetId.isEmpty else { return } DispatchQueue.main.async { [weak self] in self?.handleBubbleQuit(userId: targetId) } case "sos": // 求助 guard let userId = msg.data?.user_id, userId != AppContextManager.shared.userId, topic.contains(userId) else { return } DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.viewModel.updateMemberStatus(userId: userId, status: 1) guard let member = self.viewModel.memberList.first(where: { $0.user_id == userId }) else { return } self.showSOSReceiveAlert(for: member) } case "sosquit": let targetId = msg.data?.user_id ?? userId guard !targetId.isEmpty else { return } DispatchQueue.main.async { [weak self] in self?.handleSOSQuit(userId: targetId) } case "join", "leave", "dismiss", "kick": break default: print("📩 未处理 type=\(msg.type ?? "")") } } private func handleSOSQuit(userId: String) { viewModel.updateMemberStatus(userId: userId, status: 0) updateSelectedMemberSOSGradient() refreshSelectedMemberInfoIfNeeded(userId: userId) guard let alertView = sosReceiveAlertView, alertView.superview != nil else { return } if alertView.removeMember(userId: userId) { return } alertView.dismiss() if sosReceiveAlertView === alertView { sosReceiveAlertView = nil } stopSOSAlarm() } private func handleBubbleQuit(userId: String) { viewModel.updateMemberStatus(userId: userId, status: 0) refreshSelectedMemberInfoIfNeeded(userId: userId) syncMemberAnnotations(viewModel.memberList) if viewModel.isCurrentUser(id: userId) { if let coord = trueCoordinateByUserId["current"] ?? trueCoordinateByUserId[userId], CLLocationCoordinate2DIsValid(coord) { updateAnnotation( userId: "current", coordinate: coord, address: currentUserMember?.address ?? "" ) } return } if let coord = trueCoordinateByUserId[userId], CLLocationCoordinate2DIsValid(coord) { let address = viewModel.memberList.first(where: { $0.user_id == userId })?.lastLocation ?? "" updateAnnotation(userId: userId, coordinate: coord, address: address) } } /// 更新成员在线计数显示 private func updateOnlineCount() { let total = viewModel.memberCount let online = viewModel.memberList.filter { $0.is_online }.count // rootView.groupMemberView.setupCountData(total, online) } #if !targetEnvironment(simulator) private func rememberAnnotation(_ annotation: MemberAnnotation) { annotationByUserId[annotation.member.id] = annotation if annotation.member.isCurrentUser { currentUserAnnotation = annotation } } /// 加点后按当前 view 尺寸重投;follow 延后到下一帧再开,避免动画冻住旧投影 private func refreshAnnotationScreenPositions() { guard let mapView = rootView.mapView else { return } let shouldResumeFollow = isAutoFollowingUser if mapView.userTrackingMode != .none { mapView.setUserTrackingMode(.none, animated: false) } for annotation in mapView.annotations ?? [] { guard let memberAnn = annotation as? MemberAnnotation else { continue } let coord = memberAnn.coordinate memberAnn.coordinate = coord if let view = mapView.view(for: memberAnn) as? MemberAnnotationView { view.applyAnchor() } } mapView.setCenter(mapView.centerCoordinate, animated: false) guard shouldResumeFollow else { return } DispatchQueue.main.async { [weak self] in guard let self, self.isAutoFollowingUser else { return } self.rootView.mapView.setUserTrackingMode(.follow, animated: false) } } private func metersBetween(_ a: CLLocationCoordinate2D, _ b: CLLocationCoordinate2D) -> CLLocationDistance { CLLocation(latitude: a.latitude, longitude: a.longitude) .distance(from: CLLocation(latitude: b.latitude, longitude: b.longitude)) } private func offsetCoordinate(_ coord: CLLocationCoordinate2D, meters: CLLocationDistance, bearing: Double) -> CLLocationCoordinate2D { let earth: CLLocationDistance = 6_378_137 let latRad = coord.latitude * .pi / 180 let dLat = meters * cos(bearing) / earth * 180 / .pi let dLon = meters * sin(bearing) / (earth * cos(latRad)) * 180 / .pi return CLLocationCoordinate2D(latitude: coord.latitude + dLat, longitude: coord.longitude + dLon) } /// 20 米内的针绕真实点散开;自己钉在 GPS,别人往外偏 private func applyDisplayCoordinates() { guard let mapView = rootView.mapView else { return } let annotations = (mapView.annotations ?? []).compactMap { $0 as? MemberAnnotation } guard !annotations.isEmpty else { return } struct Pin { let id: String let isSelf: Bool let trueCoord: CLLocationCoordinate2D let annotation: MemberAnnotation } var pins: [Pin] = [] for annotation in annotations { let isSelf = annotation.member.isCurrentUser let key = isSelf ? "current" : annotation.member.id let trueCoord: CLLocationCoordinate2D if let stored = trueCoordinateByUserId[key], CLLocationCoordinate2DIsValid(stored) { trueCoord = stored } else if isSelf, let loc = lastLocation?.coordinate, CLLocationCoordinate2DIsValid(loc) { trueCoord = loc } else if let member = members.first(where: { $0.id == annotation.member.id }), CLLocationCoordinate2DIsValid(member.coordinate) { trueCoord = member.coordinate } else { continue } pins.append(Pin(id: key, isSelf: isSelf, trueCoord: trueCoord, annotation: annotation)) } var assigned = Set() for i in pins.indices { guard !assigned.contains(i) else { continue } var cluster = [i] assigned.insert(i) var k = 0 while k < cluster.count { let cur = cluster[k] for j in pins.indices where !assigned.contains(j) { if metersBetween(pins[cur].trueCoord, pins[j].trueCoord) < overlapClusterRadius { cluster.append(j) assigned.insert(j) } } k += 1 } if cluster.count == 1 { pins[cluster[0]].annotation.coordinate = pins[cluster[0]].trueCoord continue } let selfIdx = cluster.first(where: { pins[$0].isSelf }) let centroid: CLLocationCoordinate2D = { if let selfIdx { return pins[selfIdx].trueCoord } let lat = cluster.map { pins[$0].trueCoord.latitude }.reduce(0, +) / Double(cluster.count) let lon = cluster.map { pins[$0].trueCoord.longitude }.reduce(0, +) / Double(cluster.count) return CLLocationCoordinate2D(latitude: lat, longitude: lon) }() if let selfIdx { pins[selfIdx].annotation.coordinate = pins[selfIdx].trueCoord let others = cluster.filter { !pins[$0].isSelf }.sorted { pins[$0].id < pins[$1].id } for (i, idx) in others.enumerated() { let angle = 2 * Double.pi * Double(i) / Double(max(others.count, 1)) - Double.pi / 2 pins[idx].annotation.coordinate = offsetCoordinate(centroid, meters: overlapSpreadRadius, bearing: angle) } } else { let ordered = cluster.sorted { pins[$0].id < pins[$1].id } for (i, idx) in ordered.enumerated() { let angle = 2 * Double.pi * Double(i) / Double(ordered.count) - Double.pi / 2 pins[idx].annotation.coordinate = offsetCoordinate(centroid, meters: overlapSpreadRadius, bearing: angle) } } } } private func isSelectedAnnotation(_ annotation: MemberAnnotation, userId: String) -> Bool { let ids = annotationIds(matching: userId) return ids.contains(annotation.member.id) || (annotation.member.isCurrentUser && (ids.contains("current") || ids.contains(AppContextManager.shared.userId))) } private func raiseAnnotationView(_ view: MAAnnotationView) { view.zIndex = 10 var current: UIView = view while let superview = current.superview, !(superview is MAMapView) { superview.bringSubviewToFront(current) current = superview } current.superview?.bringSubviewToFront(current) } /// AMap 的 zIndex 只在 viewFor / didAdd 时生效;GPS 刷新自己的点还会把层级打乱。 /// restack=true 时先删再加,保证选中针在最上层。 private func bringAnnotationToFront(userId: String, restack: Bool = false) { guard let mapView = rootView.mapView, !userId.isEmpty else { return } var selectedAnn: MemberAnnotation? for annotation in mapView.annotations ?? [] { guard let memberAnn = annotation as? MemberAnnotation else { continue } let selected = isSelectedAnnotation(memberAnn, userId: userId) if selected { selectedAnn = memberAnn } if let view = mapView.view(for: memberAnn) as? MemberAnnotationView { if selected { view.zIndex = 10 } else if memberAnn.member.isCurrentUser { view.zIndex = 1 } else { view.zIndex = 0 } } } guard let selectedAnn else { return } if restack { mapView.removeAnnotation(selectedAnn) mapView.addAnnotation(selectedAnn) rememberAnnotation(selectedAnn) } if let view = mapView.view(for: selectedAnn) { raiseAnnotationView(view) } refreshAnnotationMoods() } private func moodIndex(for member: CircleMember) -> Int { if member.isCurrentUser { return AppContextManager.shared.account?.mood ?? 0 } return viewModel.memberList.first(where: { $0.user_id == member.id })?.mood ?? 0 } private func refreshAnnotationMoods() { guard let mapView = rootView.mapView else { return } for annotation in mapView.annotations ?? [] { guard let memberAnn = annotation as? MemberAnnotation, let view = mapView.view(for: memberAnn) as? MemberAnnotationView else { continue } view.setMood( index: moodIndex(for: memberAnn.member), selected: isSelectedAnnotation(memberAnn, userId: selectedMemberId) ) } } #endif /// 当前用户地图点 id 是 "current",MQTT 里是真实 user_id private func annotationIds(matching userId: String) -> Set { var ids: Set = [userId] if userId == "current" || viewModel.isCurrentUser(id: userId) { ids.insert("current") let mine = AppContextManager.shared.userId if !mine.isEmpty { ids.insert(mine) } } return ids } private func isMemberInBubble(_ userId: String) -> Bool { viewModel.isInBubble(userId: userId) } /// 移除指定成员的标注 private func removeAnnotation(userId: String) { #if !targetEnvironment(simulator) guard let mapView = rootView.mapView else { return } let ids = annotationIds(matching: userId) var toRemove: [MemberAnnotation] = [] var seen = Set() func append(_ annotation: MemberAnnotation?) { guard let annotation else { return } let key = ObjectIdentifier(annotation) guard !seen.contains(key) else { return } seen.insert(key) toRemove.append(annotation) } for id in ids { append(annotationByUserId[id]) } if let current = currentUserAnnotation, ids.contains(current.member.id) || (current.member.isCurrentUser && ids.contains("current")) { append(current) } for annotation in mapView.annotations?.compactMap({ $0 as? MemberAnnotation }) ?? [] { if ids.contains(annotation.member.id) { append(annotation) } } if !toRemove.isEmpty { mapView.removeAnnotations(toRemove) } for id in ids { annotationByUserId.removeValue(forKey: id) } if ids.contains("current") || ids.contains(where: { viewModel.isCurrentUser(id: $0) }) || toRemove.contains(where: { $0 === currentUserAnnotation || $0.member.isCurrentUser }) { currentUserAnnotation = nil annotationByUserId.removeValue(forKey: "current") } #endif } /// 更新指定成员的标注位置(用 MQTT 坐标,不依赖接口 last_position) private func updateAnnotation(userId: String, coordinate: CLLocationCoordinate2D, address: String) { #if !targetEnvironment(simulator) if isMemberInBubble(userId) { removeAnnotation(userId: userId) return } guard CLLocationCoordinate2DIsValid(coordinate) else { return } trueCoordinateByUserId[userId] = coordinate let memberToShow: CircleMember? = { if let idx = members.firstIndex(where: { $0.id == userId }) { let old = members[idx] let updated = CircleMember( id: old.id, name: old.name, avatar: old.avatar, isOnline: true, isOwner: old.isOwner, coordinate: coordinate, address: address, heading: old.heading, lastUpdateTime: old.lastUpdateTime, battery: old.battery ) members[idx] = updated return updated } guard let model = viewModel.memberList.first(where: { $0.user_id == userId }) else { return nil } let created = CircleMember( id: model.user_id, name: model.showName, avatar: model.displayHeadPic, isOnline: true, isOwner: viewModel.isGroupOwn(id: model.user_id), coordinate: coordinate, address: address, heading: 0, lastUpdateTime: model.last_active_time, battery: model.battery ) members.append(created) return created }() guard let member = memberToShow, let mapView = rootView.mapView else { return } if let existing = annotationByUserId[userId] { existing.coordinate = coordinate } else { let newAnn = MemberAnnotation(member: member) mapView.addAnnotation(newAnn) rememberAnnotation(newAnn) } applyDisplayCoordinates() bringAnnotationToFront(userId: selectedMemberId) #endif } } #if !targetEnvironment(simulator) // MARK: - MAMapViewDelegate extension HomeViewController: MAMapViewDelegate { func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! { if annotation is MAUserLocation { return nil } guard let memberAnnotation = annotation as? MemberAnnotation else { return nil } let identifier = memberAnnotation.member.isCurrentUser ? "MemberAnnotation.current" : "MemberAnnotation.member" var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? MemberAnnotationView if annotationView == nil { annotationView = MemberAnnotationView(annotation: memberAnnotation, reuseIdentifier: identifier) } else { annotationView?.annotation = memberAnnotation } let selectedIds = annotationIds(matching: selectedMemberId) let isSelected = selectedIds.contains(memberAnnotation.member.id) || (memberAnnotation.member.isCurrentUser && (selectedIds.contains("current") || selectedIds.contains(AppContextManager.shared.userId))) annotationView?.configure(with: memberAnnotation.member) annotationView?.setMood( index: moodIndex(for: memberAnnotation.member), selected: isSelected ) annotationView?.onTap = { [weak self] ann in self?.selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false) } rememberAnnotation(memberAnnotation) if memberAnnotation.member.isCurrentUser { annotationView?.updateHeading(currentHeading) } if isSelected { annotationView?.zIndex = 10 } else if memberAnnotation.member.isCurrentUser { annotationView?.zIndex = 1 } else { annotationView?.zIndex = 0 } return annotationView } func mapView(_ mapView: MAMapView!, didAddAnnotationViews views: [Any]!) { for case let view as MemberAnnotationView in views ?? [] { view.applyAnchor() } } func mapView(_ mapView: MAMapView!, didSelect view: MAAnnotationView!) { guard let ann = view.annotation as? MemberAnnotation else { return } mapView.deselectAnnotation(ann, animated: false) selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false) } func mapView(_ mapView: MAMapView!, didUpdate userLocation: MAUserLocation!, updatingLocation: Bool) { guard updatingLocation, let location = userLocation.location else { return } // 卫星信号 let strength = gpsSignalStrength(from: location) rootView.updateGPSSignal(bars: strength.barCount) // 地图标注 let isFirstFix = lastLocation == nil lastLocation = location MQTTService.shared.updatePhoneReportContext( groupKey: viewModel.groupModel?.default_group_key, location: location ) if isFirstFix { reportCurrentLocationIfNeeded() } Defaults[\.currentLatitude] = location.coordinate.latitude Defaults[\.currentLongitude] = location.coordinate.longitude let coordinate = location.coordinate guard CLLocationCoordinate2DIsValid(coordinate) else { return } trueCoordinateByUserId["current"] = coordinate if isMemberInBubble("current") { removeAnnotation(userId: "current") } else if let ann = currentUserAnnotation { ann.coordinate = coordinate applyDisplayCoordinates() bringAnnotationToFront(userId: selectedMemberId) } else if let me = currentUserMember { let updatedMe = CircleMember( id: "current", name: me.name, avatar: me.avatar, isOnline: true, isOwner: false, coordinate: coordinate, address: me.address, heading: me.heading, lastUpdateTime: me.lastUpdateTime, battery: me.battery ) currentUserMember = updatedMe let annotation = MemberAnnotation(member: updatedMe) mapView.addAnnotation(annotation) rememberAnnotation(annotation) applyDisplayCoordinates() bringAnnotationToFront(userId: selectedMemberId) } if isAutoFollowingUser, mapView.userTrackingMode != .follow { mapView.setUserTrackingMode(.follow, animated: false) } } func mapView(_ mapView: MAMapView!, mapWillMoveByUser wasUserAction: Bool) { guard wasUserAction else { return } stopFollowingCurrentUser() } } // MARK: - SOS 接收提示和报警音 extension HomeViewController { private var sosPlayer: AVAudioPlayer? { get { return objc_getAssociatedObject(self, &HomeViewController.sosPlayerKey) as? AVAudioPlayer } set { objc_setAssociatedObject(self, &HomeViewController.sosPlayerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } private func showSOSReceiveAlert(for member: GroupMemberModel) { if let alertView = sosReceiveAlertView, alertView.superview != nil { alertView.appendSecondary(member) playSOSAlarmIfNeeded() return } guard let window = UIApplication.keyWindow else { return } let alertView = SOSReceiveAlertView(frame: window.bounds) alertView.autoresizingMask = [.flexibleWidth, .flexibleHeight] alertView.onAcknowledged = { [weak self, weak alertView] in guard let self else { return } alertView?.dismiss() if self.sosReceiveAlertView === alertView { self.sosReceiveAlertView = nil } self.stopSOSAlarm() } window.addSubview(alertView) sosReceiveAlertView = alertView alertView.show(primary: member) playSOSAlarmIfNeeded() } private func playSOSAlarmIfNeeded() { guard !isSOSPlaying else { return } // 停止上一次报警 sosAudioStopWorkItem?.cancel() sosPlayer?.stop() sosPlayer = nil guard let url = Bundle.main.url(forResource: "sos", withExtension: "mp3") ?? Bundle.main.url(forResource: "sos", withExtension: "mp3", subdirectory: "sound") else { print("❌ SOS: sos.mp3 not found in bundle") return } do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try AVAudioSession.sharedInstance().setActive(true) } catch { print("❌ SOS: audio session error: \(error)") } guard let player = try? AVAudioPlayer(contentsOf: url) else { print("❌ SOS: failed to create AVAudioPlayer") return } print("✅ SOS: playing alarm") player.numberOfLoops = -1 player.volume = 1.0 player.play() self.sosPlayer = player isSOSPlaying = true startSOSVibration() let stopWork = DispatchWorkItem { [weak self] in self?.stopSOSAlarm() } sosAudioStopWorkItem = stopWork DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: stopWork) } private func stopSOSAlarm() { sosAudioStopWorkItem?.cancel() sosAudioStopWorkItem = nil stopSOSVibration() sosPlayer?.stop() sosPlayer = nil isSOSPlaying = false } private func startSOSVibration() { stopSOSVibration() AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) let timer = DispatchSource.makeTimerSource(queue: .main) timer.schedule(deadline: .now() + 1, repeating: 1) timer.setEventHandler { [weak self] in guard self?.isSOSPlaying == true else { return } AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } sosVibrationTimer = timer timer.resume() } private func stopSOSVibration() { sosVibrationTimer?.setEventHandler {} sosVibrationTimer?.cancel() sosVibrationTimer = nil } } #endif #if !targetEnvironment(simulator) // MARK: - CLLocationManagerDelegate (heading only) extension HomeViewController: CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { let h = newHeading.trueHeading guard h >= 0 else { return } currentHeading = h updateCurrentUserHeading() } } #endif