// // HomeViewController.swift // QuickLocation // // Created by 八条 on 2026/5/27. // import UIKit import RxSwift import RxCocoa import RxDataSources import CoreLocation import AVFoundation import SwiftyUserDefaults #if !targetEnvironment(simulator) import AMapNaviKit import CocoaMQTT import Lottie import MarqueeLabel #endif class HomeViewController: BaseViewController { override var isNavigationBarHidden: Bool { true } override var preferredStatusBarStyle: UIStatusBarStyle { .default } // MARK: - Properties fileprivate var rootView: HomeView! private var tableView: UITableView { rootView.groupMemberView.tableView } private var viewModel = HomeViewModel() private let locationManager = CLLocationManager() private var currentHeading: Double = 0 private var members: [CircleMember] = [] private var currentUserMember: CircleMember? private var currentUserAnnotation: MemberAnnotation? /// 最新位置(供 MQTT 定时上报) private var lastLocation: CLLocation? private var locationTimer: Timer? /// 当前已订阅 MQTT 的成员 ID 列表 private var subscribedMemberIds: [String] = [] /// 底部成员面板是否显示 private var isMemberPanelShown = false /// 记录每个成员最后一次收到位置的时间(MQTT userId → Date) private var lastUpdateTimes: [String: Date] = [:] /// 离线检测定时器 private var offlineCheckTimer: Timer? private var sosPlayerKey: UInt8 = 0 private var groupRefreshWorkItem: DispatchWorkItem? private var groupSwitchWorkItem: DispatchWorkItem? override func loadView() { #if !targetEnvironment(simulator) MAMapView.updatePrivacyAgree(.didAgree) MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain) #endif rootView = HomeView(frame: UIScreen.main.bounds) view = rootView } override func viewDidLoad() { super.viewDidLoad() bindViewModel() setupMap() setupHeading() reactiveAction() requestUserConfig() rootView.quickMessageView.tagListView.delegate = self // MQTT 位置上报 UIDevice.current.isBatteryMonitoringEnabled = true startLocationTimer() } // MARK: - MQTT 位置上报 private let geocoder = CLGeocoder() private var lastGeocodedCoord: CLLocationCoordinate2D? private var lastAddress: String = "" private func startLocationTimer() { locationTimer?.invalidate() locationTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in guard let self = self, let loc = self.lastLocation else { return } let coord = loc.coordinate // 坐标变化不大时复用上次地址 if let last = self.lastGeocodedCoord, hypot(coord.latitude - last.latitude, coord.longitude - last.longitude) < 0.001, !self.lastAddress.isEmpty { self.publishLocation(coord: coord, address: self.lastAddress, loc: loc) return } // 反向地理编码获取地址 self.geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, error in guard let self = self else { return } let address = placemarks?.first?.name ?? placemarks?.first?.thoroughfare ?? placemarks?.first?.locality ?? "" self.lastAddress = address self.lastGeocodedCoord = coord self.publishLocation(coord: coord, address: address, loc: loc) } } } private func publishLocation(coord: CLLocationCoordinate2D, address: String, loc: CLLocation) { MQTTService.shared.reportLocation( lat: coord.latitude, lon: coord.longitude, addr: address, speed: loc.speed, bearing: loc.course, altitude: loc.altitude, accuracy: loc.horizontalAccuracy ) } // MARK: - Actions private func reactiveAction() { // 气泡 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.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.groupMemberView.inviteJoinBtn.rx.tap.subscribe(onNext: { _ in AppRouter.push(Route.inviteJoin, userInfo: ["groupInfo": self.viewModel.groupInfo]) }).disposed(by: disposeBag) // 地图回到自己 rootView.locationView.rx.tapGesture.subscribe { _ in if let ann = self.currentUserAnnotation { self.rootView.mapView.setCenter(ann.coordinate, animated: true) } }.disposed(by: disposeBag) // User Config刷新 NotificationCenter.default.rx.notification(.RefreshUserConfigNotification, object: nil) .subscribe { [weak self] notification in self?.requestUserConfig() }.disposed(by: disposeBag) // 圈子刷新 NotificationCenter.default.rx.notification(.RefreshGroupInfoNotification, object: nil) .subscribe { [weak self] notification in self?.requestGroupInfo() }.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 } 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) } } private func bindViewModel() { viewModel.output.sectionedItems .bind(to: tableView.rx.items(dataSource: dataSource)) .disposed(by: disposeBag) let selectedMember = tableView.rx.modelSelected(GroupMemberModel.self) .share() selectedMember .subscribe(viewModel.cellAction.inputs) .disposed(by: disposeBag) // 选中成员 → 地图定位 selectedMember .subscribe(onNext: { [weak self] model in guard let self = self else { return } self.locateMember(userId: model.user_id) }) .disposed(by: disposeBag) } // MARK: - UITableViewDataSource lazy private var dataSource: RxTableViewSectionedReloadDataSource = { return RxTableViewSectionedReloadDataSource( configureCell: { (_, tableView, indexPath, model) in let cell: GroupMemberCell = tableView.dequeueReusableCell(for: indexPath) cell.configure(model: model, isCurrentUser: self.viewModel.isCurrentUser(id: model.user_id), isOwn: self.viewModel.isGroupOwn(id: model.user_id)) return cell }) }() // MARK: - API /// 获取用户配置 func requestUserConfig() { SystemService.userConfig().subscribe(onNext: { response in guard let model = response.model else { return } Defaults[\.loginToken] = model.token AppContextManager.shared.systemConfig = model.config self.getUserIMToken() // 先更新用户信息(含头像),再拉群列表同步地图标注,避免头像旧 self.requestUserInfo { [weak self] in self?.requestGroupInfo() } // 首页公告 self.requestNotice() }).disposed(by: disposeBag) } /// 获取用户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 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) { GroupService.groupInfo().subscribe { response in guard let model = response.model else { return } self.viewModel.groupModel = model NotificationCenter.default.post(name: .RefreshIMGroupListNotification, object: nil) guard isDefaultGroup else { return } self.rootView.groupMemberView.setupCountData(self.viewModel.memberCount, 1) self.rootView.groupNameLab.text = self.viewModel.groupName self.syncMemberAnnotations(model.select_group_employee) self.refreshMQTTSubscriptions(model.select_group_employee) }.disposed(by: disposeBag) } private func requestOperateGroup(groupKey: String) { GroupService.operate(opType: "setdefault", requestData: ["group_key" : groupKey]).subscribe { response in self.requestGroupInfo() }.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) // 用 mapView 的坐标转屏幕坐标判断命中 for ann in rootView.mapView.annotations?.compactMap({ $0 as? MemberAnnotation }) ?? [] { guard !ann.member.isCurrentUser else { continue } let pt = rootView.mapView.convert(ann.coordinate, toPointTo: rootView.mapView) // 30pt 命中半径 let hitRect = CGRect(x: pt.x - 30, y: pt.y - 30, width: 60, height: 60) if hitRect.contains(point) { self.isMemberPanelShown = true (self.tabBarController as? MainTabBarController)?.setTabBarHidden(true) rootView.showMemberPanel(member: ann.member) viewModel.targetUid = ann.member.id 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: "" ) // 其他成员从 select_group_employee 取,过滤掉当前用户 var others: [CircleMember] = [] for model in list where model.user_id != currentUserId || currentUserId.isEmpty { let m = CircleMember(member: model, isOwner: isGroupOwner(model.user_id)) guard CLLocationCoordinate2DIsValid(m.coordinate) else { continue } others.append(m) } let newMembers = others + [me] members = newMembers currentUserMember = me // 从 members 中过滤出在线成员用于地图标注 let onlineMembers = newMembers.filter { $0.isOnline } guard let mapView = rootView.mapView else { return } let existing = mapView.annotations?.compactMap { $0 as? MemberAnnotation } ?? [] let existingIDs = Set(existing.map { $0.member.id }) let onlineIDs = Set(onlineMembers.map { $0.id }) let toRemove = existing.filter { !onlineIDs.contains($0.member.id) } mapView.removeAnnotations(toRemove) let toAdd = onlineMembers.filter { !existingIDs.contains($0.id) } let annotations = toAdd.map { MemberAnnotation(member: $0) } mapView.addAnnotations(annotations) #endif } #if !targetEnvironment(simulator) /// 选中成员后地图定位到该成员,收起 GroupMemberView private func locateMember(userId: String) { rootView.dismissGroupMemberView() guard let member = members.first(where: { $0.id == userId }), CLLocationCoordinate2DIsValid(member.coordinate) else { return } rootView.mapView.setCenter(member.coordinate, animated: true) rootView.mapView.setZoomLevel(16, animated: 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 } /// 处理 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() // 经纬度无变化跳过地图标注更新 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 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() 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_" : "fun_" let nickName = self.viewModel.getUserNickName(id: emoteUserId) let fullText = nickName + "对你发送了表情" let attr = NSMutableAttributedString(string: fullText) attr.addAttribute(.font, value: UIFont.systemFont(ofSize: 12, weight: .medium), range: NSRange(location: 0, length: fullText.count)) attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: NSRange(location: 0, length: nickName.count)) DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.rootView.messageLab.attributedText = attr self.rootView.messageBubbleView.isHidden = false if let path = Bundle.main.path(forResource: "\(emojiFileName)\(lastChar)", ofType: "json") { self.rootView.emojiPopView.animation = LottieAnimation.filepath(path) self.rootView.bringSubviewToFront(self.rootView.emojiPopView) self.rootView.emojiPopView.isHidden = false self.rootView.emojiPopView.play { completed in self.rootView.emojiPopView.isHidden = true } } DispatchQueue.main.asyncAfter(deadline: .now() + 5) { self.rootView.messageBubbleView.isHidden = true } } } else if firstChar == "3" { // 文字 let textIdx = lastChar.integer let texts = QuickMessageView.messageList guard textIdx < texts.count else { break } let nickName = self.viewModel.getUserNickName(id: emoteUserId) let fullText = nickName + "对你说:" + texts[textIdx] let attr = NSMutableAttributedString(string: fullText) attr.addAttribute(.font, value: UIFont.systemFont(ofSize: 12, weight: .medium), range: NSRange(location: 0, length: fullText.count)) attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: NSRange(location: 0, length: nickName.count)) attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#333333"), range: NSRange(location: nickName.count, length: texts[textIdx].count)) DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.rootView.messageLab.attributedText = attr self.rootView.messageBubbleView.isHidden = false DispatchQueue.main.asyncAfter(deadline: .now() + 5) { self.rootView.messageBubbleView.isHidden = true } } } case "sos": // 求助 self.requestGroupInfo(isDefaultGroup: false) guard let userId = msg.data?.user_id, userId != AppContextManager.shared.userId else { return } playSOSAlarm() rootView.sosPopView.isHidden = false rootView.sosPopView.play(toFrame: 30.0) { completed in guard completed else { return } self.rootView.sosPopView.stop() self.rootView.sosPopView.isHidden = true } 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) } /// 移除指定成员的标注 private func removeAnnotation(userId: String) { #if !targetEnvironment(simulator) guard let mapView = rootView.mapView else { return } let toRemove = mapView.annotations?.compactMap({ $0 as? MemberAnnotation }).filter { $0.member.id == userId } if let remove = toRemove, !remove.isEmpty { mapView.removeAnnotations(remove) } #endif } /// 更新指定成员的标注位置(只更新坐标,头像/名称不变) private func updateAnnotation(userId: String, coordinate: CLLocationCoordinate2D, address: String) { #if !targetEnvironment(simulator) // 更新 members 数组 if let idx = members.firstIndex(where: { $0.id == userId }) { let old = members[idx] members[idx] = 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 ) } // 更新地图标注 guard let mapView = rootView.mapView else { return } if let oldMember = members.first(where: { $0.id == userId }), oldMember.isOnline { // 先移除旧标注,再添加新标注 let toRemove = mapView.annotations?.compactMap({ $0 as? MemberAnnotation }).filter { $0.member.id == userId } if let remove = toRemove, !remove.isEmpty { mapView.removeAnnotations(remove) } let newAnn = MemberAnnotation(member: oldMember) mapView.addAnnotation(newAnn) } #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" var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? MemberAnnotationView if annotationView == nil { annotationView = MemberAnnotationView(annotation: memberAnnotation, reuseIdentifier: identifier) } else { annotationView?.annotation = memberAnnotation } annotationView?.configure(with: memberAnnotation.member) if memberAnnotation.member.isCurrentUser { currentUserAnnotation = memberAnnotation annotationView?.updateHeading(currentHeading) } return annotationView } 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) // 地图标注 lastLocation = location Defaults[\.currentLatitude] = location.coordinate.latitude Defaults[\.currentLongitude] = location.coordinate.longitude let coordinate = location.coordinate guard CLLocationCoordinate2DIsValid(coordinate) else { return } if let ann = currentUserAnnotation { ann.coordinate = coordinate } if !isMemberPanelShown { mapView.setCenter(coordinate, animated: true) mapView.setUserTrackingMode(.follow, animated: true) } } } // MARK: - SOS 报警音 extension HomeViewController { private var sosPlayer: AVAudioPlayer? { get { return objc_getAssociatedObject(self, &sosPlayerKey) as? AVAudioPlayer } set { objc_setAssociatedObject(self, &sosPlayerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } private func playSOSAlarm() { // 停止上一次报警 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 = 0 player.volume = 1.0 player.play() self.sosPlayer = player DispatchQueue.main.asyncAfter(deadline: .now() + 15) { [weak self] in self?.sosPlayer?.stop() self?.sosPlayer = nil } } } // MARK: - 快捷消息 & 表情 extension HomeViewController: TTGTextTagCollectionViewDelegate { func textTagCollectionView(_ textTagCollectionView: TTGTextTagCollectionView!, didTap tag: TTGTextTag!, at index: UInt) { let emoteIdx = "3\(index)".integer requestSendEmote(emoteIdx: emoteIdx) } func requestSendEmote(emoteIdx: Int) { guard let model = viewModel.groupModel else { return } DLToast.showLoading() UserService.sendEmote(emoteIdx: emoteIdx, groupKey: model.default_group_key, targetUid: viewModel.targetUid).subscribe(onNext: { response in DLToast.show(text: "发送成功") }, onError: { _ in }).disposed(by: disposeBag) } } #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