825 lines
34 KiB
Swift
825 lines
34 KiB
Swift
//
|
||
// 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: 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?
|
||
|
||
private 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 = HomeView(frame: UIScreen.main.bounds)
|
||
view = rootView
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
bindViewModel()
|
||
setupMap()
|
||
setupHeading()
|
||
reactiveAction()
|
||
|
||
requestUserConfig()
|
||
|
||
rootView.quickMessageView.tagListView.delegate = self
|
||
// 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.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.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)
|
||
}
|
||
|
||
// 发送表情
|
||
rootView.interactionView.onSendEmote = { [weak self] emoteIdx in
|
||
guard let self = self else { return }
|
||
self.requestSendEmote(emoteIdx: emoteIdx)
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
// 首页公告
|
||
self.requestNotice()
|
||
}).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(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.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 中过滤出在线成员用于地图标注
|
||
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 {
|
||
/// 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
|
||
}
|
||
|
||
/// 处理 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 "sos": // 求助
|
||
self.requestGroupInfo(isDefaultGroup: false)
|
||
guard let userId = msg.data?.user_id,
|
||
userId != AppContextManager.shared.userId else { return }
|
||
playSOSAlarm()
|
||
rootView.sosPopView.isHidden = false
|
||
rootView.sosPopView.play(toFrame: 30.0) { completed in
|
||
guard completed else { return }
|
||
self.rootView.sosPopView.stop()
|
||
self.rootView.sosPopView.isHidden = true
|
||
}
|
||
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)
|
||
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 }
|
||
|
||
// 卫星信号
|
||
let strength = gpsSignalStrength(from: location)
|
||
rootView.updateGPSSignal(bars: strength.barCount)
|
||
|
||
// 地图标注
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - SOS 报警音
|
||
extension HomeViewController {
|
||
|
||
private var sosPlayer: AVAudioPlayer? {
|
||
get { return objc_getAssociatedObject(self, &sosPlayerKey) as? AVAudioPlayer }
|
||
set { objc_setAssociatedObject(self, &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 = 0
|
||
player.volume = 1.0
|
||
player.play()
|
||
self.sosPlayer = player
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 15) { [weak self] in
|
||
self?.sosPlayer?.stop()
|
||
self?.sosPlayer = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|