jsdw_ios/QuickLocation/Section/Home/HomeViewController.swift

1005 lines
42 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 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: HomeView2!
private var memberCV: UICollectionView {
rootView.groupMemberView.memberCV
}
private var viewModel = HomeViewModel()
/// GroupMemberListCell
private var selectedMemberId: String = ""
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
private var isSOSPlaying = false
/// MQTT userId Date
private var lastUpdateTimes: [String: Date] = [:]
/// 线
private var offlineCheckTimer: Timer?
private var popupQueue: PopupQueueManager?
private static 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 = HomeView2(frame: UIScreen.main.bounds)
view = rootView
}
override func viewDidLoad() {
super.viewDidLoad()
bindViewModel()
setupMap()
setupHeading()
reactiveAction()
// rootView.quickMessageView.tagListView.delegate = self
// MQTT
UIDevice.current.isBatteryMonitoringEnabled = true
startLocationTimer()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let manager = AuthorizeManager.manager(type: .locationAlways)
rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// sosPlayer?.stop()
}
// 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.scheduleView.rx.tapGesture.subscribe { _ in
// guard let model = self.viewModel.groupModel else { return }
// AppRouter.push(Route.groupSchedule, userInfo: ["groupKey": model.default_group_key])
// }.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.inviteBtn.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)
.startWith(Notification(name: .RefreshUserConfigNotification))
.do(onNext: { [weak self] _ in
self?.popupQueue = nil
})
.flatMapLatest { _ in
SystemService.userConfig()
}
.subscribe(onNext: { [weak self] response in
guard let self = self, let model = response.model else { return }
Defaults[\.loginToken] = model.token
AppContextManager.shared.systemConfig = model.config
// rootView.searchLottieView.isHidden = model.config?.isIntercept == true
self.getUserIMToken()
self.requestUserInfo { [weak self] in
self?.requestGroupInfo()
}
self.requestNotice()
self.popupQueue = nil
self.popupQueue = PopupQueueManager()
self.popupQueue?.start(from: self)
}).disposed(by: disposeBag)
//
NotificationCenter.default.rx.notification(.RefreshGroupInfoNotification, object: nil)
.subscribe { [weak self] notification in
self?.requestGroupInfo()
}.disposed(by: disposeBag)
//
NotificationCenter.default.rx.notification(.ShowMemberLocationNotification, object: nil)
.subscribe { [weak self] notification in
guard let self = self,
let userInfo = notification.userInfo,
let last_position = userInfo["last_position"] as? String,
let group_key = userInfo["group_key"] as? String else { return }
// "lat:lng:address"
let parts = last_position.components(separatedBy: ":")
let coord: CLLocationCoordinate2D?
if parts.count >= 2, let lat = Double(parts[0]), let lng = Double(parts[1]) {
coord = CLLocationCoordinate2D(latitude: lat, longitude: lng)
} else {
coord = nil
}
//
GroupService.operate(opType: "setdefault", requestData: ["group_key": group_key]).subscribe { _ in
self.requestGroupInfo()
if let c = coord, CLLocationCoordinate2DIsValid(c) {
self.rootView.mapView.setCenter(c, animated: true)
self.rootView.mapView.setZoomLevel(16, animated: true)
}
}.disposed(by: self.disposeBag)
}.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 }
// self.rootView.dismissMemberPanel()
// 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)
// }
//
// rootView.interactionView.onShare = {
// SharePopView.show()
// }
}
private func bindViewModel() {
viewModel.output.sectionedItems
.do(onNext: { [weak self] sections in
guard let self = self else { return }
let items = sections.flatMap(\.items)
guard !items.isEmpty else { return }
let stillValid = items.contains { $0.user_id == self.selectedMemberId }
if self.selectedMemberId.isEmpty || !stillValid {
if let me = items.first(where: { self.viewModel.isCurrentUser(id: $0.user_id) }) {
self.selectedMemberId = me.user_id
self.rootView.groupMemberView.setupMemberInfo(
me,
isOwner: self.viewModel.isGroupOwn(id: me.user_id)
)
} else {
self.selectedMemberId = items[0].user_id
self.rootView.groupMemberView.setupMemberInfo(
items[0],
isOwner: self.viewModel.isGroupOwn(id: items[0].user_id)
)
}
}
})
.bind(to: memberCV.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
let selectedMember = memberCV.rx.modelSelected(GroupMemberModel.self)
.share()
selectedMember
.subscribe(viewModel.cellAction.inputs)
.disposed(by: disposeBag)
// +
selectedMember
.subscribe(onNext: { [weak self] model in
self?.selectMember(userId: model.user_id, centerMap: true, dismissPanel: true)
})
.disposed(by: disposeBag)
}
// MARK: - UICollectionViewDataSource
lazy private var dataSource: RxCollectionViewSectionedReloadDataSource<GroupMemberListSectionModel> = {
RxCollectionViewSectionedReloadDataSource<GroupMemberListSectionModel> { [weak self] _, collectionView, indexPath, model in
let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath)
cell.configure(model: model,
isCurrentUser: self?.viewModel.isCurrentUser(id: model.user_id) ?? false,
isSelected: model.user_id == self?.selectedMemberId)
return cell
}
}()
// MARK: - API
/// 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.groupMemberView.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 线 GPS
let onlineMembers = newMembers.filter { $0.isOnline && !$0.isCurrentUser }
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)
if toRemove.contains(where: { $0 === currentUserAnnotation }) {
currentUserAnnotation = nil
}
let toAdd = onlineMembers.filter { !existingIDs.contains($0.id) }
let annotations = toAdd.map { MemberAnnotation(member: $0) }
mapView.addAnnotations(annotations)
if currentUserAnnotation == nil,
let me = currentUserMember,
let loc = lastLocation,
CLLocationCoordinate2DIsValid(loc.coordinate) {
let updatedMe = CircleMember(
id: "current",
name: me.name,
avatar: me.avatar,
isOnline: true,
isOwner: false,
coordinate: loc.coordinate,
address: me.address,
heading: me.heading,
lastUpdateTime: me.lastUpdateTime,
battery: me.battery
)
currentUserMember = updatedMe
mapView.addAnnotation(MemberAnnotation(member: updatedMe))
}
#endif
}
/// /
private func selectMember(userId: String, centerMap: Bool = true, dismissPanel: Bool = false) {
let listUserId = (userId == "current") ? AppContextManager.shared.userId : userId
guard !listUserId.isEmpty else { return }
selectedMemberId = listUserId
if let model = viewModel.memberList.first(where: { $0.user_id == listUserId }) {
rootView.groupMemberView.setupMemberInfo(
model,
isOwner: viewModel.isGroupOwn(id: model.user_id)
)
}
memberCV.reloadData()
if let idx = viewModel.memberList.firstIndex(where: { $0.user_id == listUserId }) {
let indexPath = IndexPath(item: idx, section: 0)
DispatchQueue.main.async { [weak self] in
guard let self = self,
self.memberCV.numberOfSections > 0,
self.memberCV.numberOfItems(inSection: 0) > idx else { return }
self.memberCV.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
}
}
#if !targetEnvironment(simulator)
if dismissPanel {
rootView.dismissGroupMemberView()
}
if centerMap {
centerMapOnMember(userId: userId)
}
#endif
}
#if !targetEnvironment(simulator)
/// id "current" userId
private func centerMapOnMember(userId: String) {
let coordinate: CLLocationCoordinate2D?
if userId == "current" || viewModel.isCurrentUser(id: userId) {
coordinate = currentUserAnnotation?.coordinate
?? lastLocation?.coordinate
} else {
coordinate = members.first(where: { $0.id == userId })?.coordinate
}
guard let coord = coordinate, CLLocationCoordinate2DIsValid(coord) else { return }
rootView.mapView.setCenter(coord, animated: true)
rootView.mapView.setZoomLevel(16, animated: true)
}
/// GroupMemberView
private func locateMember(userId: String) {
selectMember(userId: userId, centerMap: true, dismissPanel: 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
}
// MARK: - 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 "bubble": //
guard let userId = msg.data?.user_id else { return }
DispatchQueue.main.async { [weak self] in
self?.viewModel.updateMemberStatus(userId: userId, status: 2)
self?.removeAnnotation(userId: userId)
}
case "sos": //
guard let userId = msg.data?.user_id,
userId != AppContextManager.shared.userId,
topic.contains(userId) else { return }
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.viewModel.updateMemberStatus(userId: userId, status: 1)
guard !self.isSOSPlaying else { return }
self.isSOSPlaying = true
self.playSOSAlarm()
// self.rootView.sosPopView.isHidden = false
// self.rootView.sosPopView.play { completed in
// self.rootView.sosPopView.stop()
// self.rootView.sosPopView.isHidden = true
// }
// DispatchQueue.main.asyncAfter(deadline: .now() + 15) { [weak self] in
// self?.sosPlayer?.stop()
// self?.sosPlayer = nil
// self?.rootView.sosPopView.stop()
// self?.rootView.sosPopView.isHidden = true
// self?.isSOSPlaying = false
// }
}
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)
annotationView?.onTap = { [weak self] ann in
self?.selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false)
}
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
} else if let me = currentUserMember {
let updatedMe = CircleMember(
id: "current",
name: me.name,
avatar: me.avatar,
isOnline: true,
isOwner: false,
coordinate: coordinate,
address: me.address,
heading: me.heading,
lastUpdateTime: me.lastUpdateTime,
battery: me.battery
)
currentUserMember = updatedMe
let annotation = MemberAnnotation(member: updatedMe)
mapView.addAnnotation(annotation)
}
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, &HomeViewController.sosPlayerKey) as? AVAudioPlayer }
set { objc_setAssociatedObject(self, &HomeViewController.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 = -1
player.volume = 1.0
player.play()
self.sosPlayer = player
}
}
// 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