// // HomeViewController.swift // QuickLocation // // Created by 八条 on 2026/5/27. // import UIKit import RxSwift import RxCocoa import RxDataSources import CoreLocation import SwiftyUserDefaults #if !targetEnvironment(simulator) import AMapNaviKit import CocoaMQTT #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? 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() // MQTT 位置上报 UIDevice.current.isBatteryMonitoringEnabled = true startLocationTimer() startOfflineCheckTimer() } // 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 startOfflineCheckTimer() { offlineCheckTimer?.invalidate() offlineCheckTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in self?.checkOfflineMembers() } } /// 检查超时未更新的成员,标记离线 private func checkOfflineMembers() { let now = Date() for (userId, lastTime) in lastUpdateTimes { guard userId != AppContextManager.shared.userId, now.timeIntervalSince(lastTime) > 60 else { continue } viewModel.setMemberOffline(userId: userId) removeAnnotation(userId: userId) updateOnlineCount() } } 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.signInView.rx.tapGesture.subscribe { _ in let vc = SignInVC(currentUserCoord: self.lastLocation?.coordinate) 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) } } 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() } }).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() { GroupService.groupInfo().subscribe { response in guard let model = response.model else { return } self.viewModel.groupModel = model 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) } // 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) 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 } let mapView = rootView.mapView 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 { /// 切换圈子时刷新 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)") 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() // 更新地图标注位置(离线则不显示标注) if isOnline { self.updateAnnotation(userId: userId, coordinate: coord, address: firstPoint.addr) } else { self.removeAnnotation(userId: userId) } } case "disconnect": self.removeAnnotation(userId: userId) viewModel.setMemberOffline(userId: userId) updateOnlineCount() case "emote": guard let index = msg.data?.index else { break } if index >= 30 { // 文字 guard let gk = msg.data?.group_key, gk.components(separatedBy: "/").count >= 2 else { break } let parts = gk.components(separatedBy: "/") let emoteUserId = parts[1] let textIdx = index % 10 let texts = QuickMessageView.messageList guard textIdx < texts.count else { break } let nickName = self.viewModel.getUserNickName(id: emoteUserId) let tip = nickName let fullText = tip + "对你说:" + 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: tip.count)) attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#333333"), range: NSRange(location: tip.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 } } } 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) let mapView = rootView.mapView 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 ) } // 更新地图标注 let mapView = rootView.mapView 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 } lastLocation = location 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) } } func mapViewRequireLocationAuth(_ locationManager: CLLocationManager!) { locationManager.requestAlwaysAuthorization() } func mapView(_ mapView: MAMapView!, didSelect view: MAAnnotationView!) { guard let annotationView = view as? MemberAnnotationView, let annotation = annotationView.annotation as? MemberAnnotation, !annotation.member.isCurrentUser else { return } // 弹出底部面板 rootView.showMemberPanel(member: annotation.member) } func mapView(_ mapView: MAMapView!, didDeselect view: MAAnnotationView!) { guard let annotationView = view as? MemberAnnotationView, let annotation = annotationView.annotation as? MemberAnnotation, !annotation.member.isCurrentUser else { return } rootView.dismissMemberPanel() } } #endif // 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() } }