// // 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 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 lazy var mockUnlockStartTimes: [Date] = [ Date().addingTimeInterval(-(11 * 3600 + 11 * 60)), Date().addingTimeInterval(-(3 * 3600 + 28 * 60)), Date().addingTimeInterval(-(25 * 60)) ] private static var sosPlayerKey: UInt8 = 0 private var groupRefreshWorkItem: DispatchWorkItem? private var groupSwitchWorkItem: DispatchWorkItem? private var groupInfoRequestID = UUID() 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() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) UnlockCountManager.shared.refreshAuthorizationAndMonitoring() let manager = AuthorizeManager.manager(type: .locationAlways) rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized } 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 = placemarks?.first?.name ?? placemarks?.first?.thoroughfare ?? placemarks?.first?.locality ?? "" DispatchQueue.main.async { guard let self else { return } if !address.isEmpty { self.lastAddress = address } self.lastGeocodedCoord = coord } } } 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.onUnlockRequestTap = { [weak self] in guard let self else { return } let requests = self.makeMockUnlockRequests() UnlockRequestPopView.show( requests: requests, onUnlock: { _ in // 预留解锁回调,后续接入 AppRestrict / API。 }, onReject: { _ in // 预留拒绝回调,后续接入 API。 } ) } rootView.onUnlockReceiveTap = { [weak self] in guard let self else { return } ReceiveMessagePopView.show(items: self.makeMockReceiveMessages()) { _ in // 预留阅后即焚完成回调,后续接入消息销毁接口。 } } // 气泡 // 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 guard let self = self, let groupModel = self.viewModel.groupModel else { return } GroupListPopView.show(groupModel: groupModel, currentMembers: self.viewModel.memberList) { [weak self] groupKey in guard let self = self, let key = groupKey else { return } self.requestOperateGroup(groupKey: key) } }).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 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 self?.openPhoneReportDetail() }).disposed(by: disposeBag) // 地图回到自己并重新开启自动跟随 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 // rootView.searchLottieView.isHidden = model.config?.isIntercept == true self.getUserIMToken() self.requestUserInfo { [weak self] in self?.requestGroupInfo() } self.requestNotice() 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?.requestGroupInfo() }.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 self.requestGroupInfo() 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.rootView.setSOSGradientActive(false) 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.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 /// 获取用户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 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( member: member, lockStartTime: mockUnlockStartTimes[index] ) } } private func makeMockReceiveMessages() -> [ReceiveMessageDisplayItem] { var candidates = viewModel.memberList let fallbackImage = UIImage(named: "UserIcon/1") let mockMessages = [ "想对你说的话都藏在这里,请查收呀", "今天也要开开心心,注意安全哦" ] guard !candidates.isEmpty else { return [ ReceiveMessageDisplayItem( senderName: "圈子成员", avatar: fallbackImage, image: fallbackImage, message: mockMessages[0] ), ReceiveMessageDisplayItem( senderName: "圈子成员", avatar: fallbackImage, image: fallbackImage, message: mockMessages[1], kind: .voice, duration: 10 ) ] } if let selectedIndex = candidates.firstIndex(where: { $0.user_id == selectedMemberId }) { let selectedMember = candidates.remove(at: selectedIndex) candidates.insert(selectedMember, at: 0) } while candidates.count < 2 { candidates.append(contentsOf: candidates) } return Array(candidates.prefix(2)).enumerated().map { index, member in let memberImage = member.userIcon let displayImage = memberImage.size == .zero ? fallbackImage : memberImage return ReceiveMessageDisplayItem( senderName: member.nick_name, avatar: displayImage, image: displayImage, message: mockMessages[index], kind: index == 1 ? .voice : .image, duration: index == 1 ? 10 : 0 ) } } 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 requestGroupInfo(isDefaultGroup: Bool=true) { let requestID = UUID() groupInfoRequestID = requestID GroupService.groupInfo().subscribe(onNext: { [weak self] response in guard let self, self.groupInfoRequestID == requestID, let model = response.model else { return } let previousGroupKey = self.viewModel.groupModel?.default_group_key let requestedGroupKey = model.default_group_key let isSwitchingGroup = previousGroupKey != nil && previousGroupKey != requestedGroupKey self.viewModel.groupModel = model NotificationCenter.default.post(name: .RefreshIMGroupListNotification, object: nil) if isDefaultGroup { self.rootView.groupMemberView.groupNameLab.text = self.viewModel.groupName } if isSwitchingGroup || requestedGroupKey.isEmpty { self.viewModel.clearMembers() self.syncMemberAnnotations([]) self.refreshMQTTSubscriptions([]) } guard !requestedGroupKey.isEmpty else { return } self.requestGroupMembers(groupKey: requestedGroupKey, requestID: requestID) }, onError: { error in DLToast.showError(text: error.localizedDescription) }).disposed(by: disposeBag) } private func requestGroupMembers(groupKey: String, requestID: UUID) { 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 { DLToast.showError(text: response.message ?? "获取圈子成员失败") return } self.viewModel.updateMembers(response.list) // 成员接口是气泡结束的权威来源:status=0 时重新显示地图点。 self.syncMemberAnnotations(self.viewModel.memberList) self.refreshMQTTSubscriptions(self.viewModel.memberList) self.updateOnlineCount() }, onError: { [weak self] error in guard let self, self.groupInfoRequestID == requestID, self.viewModel.groupModel?.default_group_key == groupKey else { return } DLToast.showError(text: error.localizedDescription) }) .disposed(by: disposeBag) } private func requestOperateGroup(groupKey: String) { if viewModel.groupModel?.default_group_key != groupKey { groupInfoRequestID = UUID() viewModel.clearMembers() syncMemberAnnotations([]) refreshMQTTSubscriptions([]) } GroupService.operate(opType: "setdefault", requestData: ["group_key" : groupKey]) .subscribe(onNext: { [weak self] _ in self?.requestGroupInfo() }, onError: { error in DLToast.showError(text: error.localizedDescription) }) .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 var lastUpdateTime: Int64 = 0 if let currentModel = list.first(where: { $0.user_id == AppContextManager.shared.userId }) { lastUpdateTime = currentModel.last_active_time } // 当前用户始终由 GPS 定位,单独注入 let me = CircleMember( id: "current", name: AppContextManager.shared.name, avatar: AppContextManager.shared.account?.head_pic ?? "1", isOnline: true, isOwner: false, coordinate: kCLLocationCoordinate2DInvalid, address: "", heading: 0, lastUpdateTime: lastUpdateTime, 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 // 从 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 } /// 若更新的是当前选中成员,刷新信息卡(位置 / 在线 / 距离等) private func refreshSelectedMemberInfoIfNeeded(userId: String) { guard userId == selectedMemberId, let model = viewModel.memberList.first(where: { $0.user_id == userId }) else { return } rootView.groupMemberView.setupMemberInfo( model, isOwner: viewModel.isGroupOwn(id: model.user_id) ) } 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 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) memberCV.reloadData() 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 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 { /// join/leave/dismiss 防抖刷新(500ms 内多次触发只调一次) private func debounceGroupSwitch(groupKey: String) { groupSwitchWorkItem?.cancel() let work = DispatchWorkItem { [weak self] in self?.requestOperateGroup(groupKey: groupKey) } groupSwitchWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work) } private func debounceGroupRefresh() { groupRefreshWorkItem?.cancel() let work = DispatchWorkItem { [weak self] in self?.requestGroupInfo() } groupRefreshWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work) } /// 切换圈子时刷新 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 } // 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 "emote": // 快捷消息、表情 guard let userId = msg.data?.user_id, userId == AppContextManager.shared.userId, // 只接收发给我的 let index = msg.data?.index, index > 9, let gk = msg.data?.group_key, gk.components(separatedBy: "/").count >= 2 else { break } let firstChar = index.string.prefix(1) let lastChar = String(index.string.suffix(index.string.count-1)) let parts = gk.components(separatedBy: "/") let emoteUserId = parts[1] 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 "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 "join": // 圈子有成员加入 guard let gk = msg.data?.group_key, let defaultGk = self.viewModel.groupModel?.default_group_key, gk == defaultGk else { self.requestGroupInfo(isDefaultGroup: false) return } debounceGroupRefresh() case "leave": // 圈子有成员离开 guard let userId = msg.data?.user_id, let gk = msg.data?.group_key, let defaultGk = self.viewModel.groupModel?.default_group_key, defaultGk.hasPrefix(gk) else { self.requestGroupInfo(isDefaultGroup: false) return } // 是当前选中圈子就移除成员 if userId == AppContextManager.shared.userId { // 当前用户就切换到第一个圈子 guard let model = self.viewModel.groupModel, let groupInfoModel = model.groups.first else { return } if model.groups.count > 1 { self.debounceGroupSwitch(groupKey: groupInfoModel.group_key) } else { debounceGroupRefresh() } } else { // 移除成员 guard self.viewModel.memberIsExist(userId: userId) else { return } self.viewModel.removeMember(userId: userId) self.removeAnnotation(userId: userId) // self.rootView.groupMemberView.setupCountData(self.viewModel.memberList.count, 1) MQTTService.shared.unsubscribe(topic: "smartdrive/\(userId)") } case "dismiss": // 圈子解散 guard let gk = msg.data?.group_key, let defaultGk = self.viewModel.groupModel?.default_group_key, gk == defaultGk, let model = self.viewModel.groupModel, let groupInfoModel = model.groups.first else { self.requestGroupInfo(isDefaultGroup: false) return } if model.groups.count > 1 { self.debounceGroupSwitch(groupKey: groupInfoModel.group_key) } else { debounceGroupRefresh() } default: print("📩 未处理 type=\(msg.type ?? "")") } } /// 更新成员在线计数显示 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.nick_name, avatar: model.head_pic, 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 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