542 lines
20 KiB
Swift
542 lines
20 KiB
Swift
//
|
||
// 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<GroupMemberListSectionModel> = {
|
||
return RxTableViewSectionedReloadDataSource<GroupMemberListSectionModel>(
|
||
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()
|
||
}
|
||
}
|