jsdw_ios/QuickLocation/Section/Home/HomeViewController.swift

542 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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()
}
}