// // 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 MAMapKit 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 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() } // 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.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) } // MQTT 接收 MQTTService.shared.onMessageReceived = { message, id, properties in print("收到消息: \(message.string ?? "")") } } 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, self.viewModel.memberOnlineCount) 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 // 当前用户始终由 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, lastUpdateText: "在线", 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 let mapView = rootView.mapView let existing = mapView.annotations?.compactMap { $0 as? MemberAnnotation } ?? [] let existingIDs = Set(existing.map { $0.member.id }) let newIDs = Set(newMembers.map { $0.id }) let toRemove = existing.filter { !newIDs.contains($0.member.id) } mapView.removeAnnotations(toRemove) let toAdd = newMembers.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 } /// 处理成员位置更新(从 topic 提取 userId,更新地图标注) private func handleMemberLocation(topic: String, payload: String?) { guard let payload = payload, let data = payload.data(using: .utf8), let msg = try? JSONDecoder().decode(MqttIncomingMessage.self, from: data), let firstPoint = msg.data?.points?.first else { return } print("📩 收到消息 -> 主题:\(topic),内容:\(msg)") let userId = topic.replacingOccurrences(of: "smartdrive/", with: "") 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 lastUpdateText: String if isOnline { lastUpdateText = "" } else if diffSec < 3600 { lastUpdateText = "\(Int(diffSec / 60))分钟前" } else if diffSec < 86400 { lastUpdateText = "\(Int(diffSec / 3600))小时前" } else { lastUpdateText = "\(Int(diffSec / 86400))天前" } let battery = msg.data?.battery ?? "" DispatchQueue.main.async { [weak self] in guard let self = self else { return } // 更新列表(地址、电量、在线状态) self.viewModel.updateMemberLocation( userId: userId, lat: firstPoint.lat, lon: firstPoint.lon, address: firstPoint.addr, battery: battery, isOnline: isOnline, lastUpdateText: lastUpdateText ) // 更新地图标注位置 self.updateAnnotation(userId: userId, coordinate: coord, address: firstPoint.addr) } } /// 更新指定成员的标注位置(只更新坐标,头像/名称不变) 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, lastUpdateText: "在线", battery: old.battery ) } // 更新地图标注坐标 let mapView = rootView.mapView for ann in mapView.annotations?.compactMap({ $0 as? MemberAnnotation }) ?? [] { if ann.member.id == userId { ann.coordinate = coordinate break } } #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() } }