jsdw_ios/QuickLocation/Section/Home/HomeViewController.swift

2139 lines
86 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 AudioToolbox
import SwiftyUserDefaults
#if !targetEnvironment(simulator)
import AMapNaviKit
import CocoaMQTT
import Lottie
import MarqueeLabel
import ObjectMapper
#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 var inboxMessages: [PigeonSentMessage] = []
private var unlockRequestItems: [PhoneUnlockRequestItem] = []
private var phoneUsageCache: [String: PhoneUsageTodayModel] = [:]
private var phoneUsageRequestIDs: [String: UUID] = [:]
private let locationManager = CLLocationManager()
private var currentHeading: Double = 0
private var members: [CircleMember] = []
private var currentUserMember: CircleMember?
private var currentUserAnnotation: MemberAnnotation?
#if !targetEnvironment(simulator)
/// 自己持有的地图点,避免只靠 mapView.annotations 对不上同一实例
private var annotationByUserId: [String: MemberAnnotation] = [:]
/// 散开前的真实坐标,annotation.coordinate 可能是展示偏移
private var trueCoordinateByUserId: [String: CLLocationCoordinate2D] = [:]
private let overlapClusterRadius: CLLocationDistance = 20
private let overlapSpreadRadius: CLLocationDistance = 12
#endif
/// 最新位置(供 MQTT 定时上报)
private var lastLocation: CLLocation?
private var locationTimer: Timer?
/// 当前已订阅 MQTT 的成员 ID 列表
private var subscribedMemberIds: [String] = []
/// 底部成员面板是否显示
private var isMemberPanelShown = false
/// 地图是否自动跟随当前用户;滑动查看后关闭,点击定位按钮后重新打开
private var isAutoFollowingUser = true
private var isSOSPlaying = false
private var sosReceiveAlertView: SOSReceiveAlertView?
private var sosAudioStopWorkItem: DispatchWorkItem?
private var sosVibrationTimer: DispatchSourceTimer?
/// 记录每个成员最后一次收到位置的时间(MQTT userId → Date)
private var lastUpdateTimes: [String: Date] = [:]
/// 离线检测定时器
private var offlineCheckTimer: Timer?
private static var sosPlayerKey: UInt8 = 0
private var groupRefreshWorkItem: DispatchWorkItem?
private var groupDataRetryWorkItem: DispatchWorkItem?
private var groupInfoRequestID = UUID()
private var pendingGroupDataChanges: [GroupDataChange] = []
private var hasAppeared = false
private var didRequestWhenInUseLocation = false
private struct GroupDataChange {
let groupKey: String
let userId: String
let operation: String
let source: String
init(notification: Notification) {
groupKey = notification.userInfo?[GroupDataChangeUserInfoKey.groupKey] as? String ?? ""
userId = notification.userInfo?[GroupDataChangeUserInfoKey.userId] as? String ?? ""
operation = notification.userInfo?[GroupDataChangeUserInfoKey.operation] as? String ?? ""
source = notification.userInfo?[GroupDataChangeUserInfoKey.source] as? String ?? ""
}
var isMQTT: Bool {
source == GroupDataChangeSource.mqtt
}
}
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()
querySelfLocked()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
rootView.groupMemberView.refreshLocalDeviceInfoIfNeeded()
requestWhenInUseLocationIfNeeded()
refreshLocationWarning()
refreshPigeonInbox()
queryUnlockRequests()
if hasAppeared {
scheduleGroupDataRefresh()
} else {
hasAppeared = true
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// sosPlayer?.stop()
}
private func requestWhenInUseLocationIfNeeded() {
guard !didRequestWhenInUseLocation else { return }
let manager = AuthorizeManager.manager(type: .locationWhenInUse)
guard manager?.authorizeStatus() == .notDetermined else { return }
didRequestWhenInUseLocation = true
manager?.authorize { [weak self] _ in
self?.refreshLocationWarning()
}
}
private func refreshLocationWarning() {
let manager = AuthorizeManager.manager(type: .locationAlways)
rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized
}
// MARK: - MQTT 位置上报
private let geocoder = CLGeocoder()
private var lastGeocodedCoord: CLLocationCoordinate2D?
private var lastAddress: String = ""
private func startLocationTimer() {
locationTimer?.invalidate()
let timer = Timer(timeInterval: 5, repeats: true) { [weak self] _ in
self?.reportCurrentLocationIfNeeded()
}
RunLoop.main.add(timer, forMode: .common)
locationTimer = timer
reportCurrentLocationIfNeeded()
}
private func reportCurrentLocationIfNeeded() {
guard !AppContextManager.shared.userId.isEmpty,
let loc = lastLocation,
CLLocationCoordinate2DIsValid(loc.coordinate) else { return }
let coord = loc.coordinate
publishLocation(coord: coord, address: lastAddress, loc: loc)
refreshAddressIfNeeded(coord: coord, loc: loc)
}
private func refreshAddressIfNeeded(coord: CLLocationCoordinate2D, loc: CLLocation) {
if let last = lastGeocodedCoord,
hypot(coord.latitude - last.latitude, coord.longitude - last.longitude) < 0.001 {
return
}
geocoder.cancelGeocode()
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
let address = self?.mqttAddress(from: placemarks?.first) ?? ""
DispatchQueue.main.async {
guard let self else { return }
if !address.isEmpty {
self.lastAddress = address
}
self.lastGeocodedCoord = coord
}
}
}
private func mqttAddress(from placemark: CLPlacemark?) -> String {
guard let placemark else { return "" }
let province = placemark.administrativeArea?.trimmed ?? ""
let city = placemark.locality?.trimmed
?? placemark.subAdministrativeArea?.trimmed
?? ""
let district = placemark.subLocality?.trimmed ?? ""
let detail = placemark.name?.trimmed
?? placemark.thoroughfare?.trimmed
?? ""
let municipalityNames: Set<String> = ["北京", "上海", "天津", "重庆"]
var components: [String] = []
if !province.isEmpty {
components.append(province)
}
let provinceName = province.replacingOccurrences(of: "市", with: "")
let cityName = city.replacingOccurrences(of: "市", with: "")
let isMunicipality = municipalityNames.contains(provinceName) && provinceName == cityName
if !city.isEmpty, !isMunicipality, !components.contains(city) {
components.append(city)
}
if !district.isEmpty, !components.contains(district) {
components.append(district)
}
var remainingDetail = detail
for component in components where remainingDetail.hasPrefix(component) {
remainingDetail.removeFirst(component.count)
}
if !remainingDetail.isEmpty, !components.contains(remainingDetail) {
components.append(remainingDetail)
}
return components.joined()
}
private func publishLocation(coord: CLLocationCoordinate2D, address: String, loc: CLLocation) {
let work = {
MQTTService.shared.reportLocation(
lat: coord.latitude,
lon: coord.longitude,
addr: address,
speed: loc.speed,
bearing: loc.course,
altitude: loc.altitude,
accuracy: loc.horizontalAccuracy
)
}
if Thread.isMainThread {
work()
} else {
DispatchQueue.main.async(execute: work)
}
}
// MARK: - Actions
private func reactiveAction() {
rootView.groupMemberView.onCoupleMemberTap = { [weak self] member in
self?.selectMember(userId: member.user_id, centerMap: true, dismissPanel: true)
}
rootView.onUnlockRequestTap = { [weak self] in
guard let self else { return }
let requests = self.makeUnlockRequestDisplayItems()
guard !requests.isEmpty else { return }
UnlockRequestPopView.show(
requests: requests,
onUnlock: { [weak self] item, done in
self?.unlockMember(item, done: done)
},
onReject: { _ in }
)
}
rootView.onUnlockReceiveTap = { [weak self] in
guard let self else { return }
let items = self.inboxMessages.map(self.makeReceiveDisplayItem)
guard !items.isEmpty else { return }
ReceiveMessagePopView.show(
items: items,
onRequestBurn: { [weak self] item, done in
self?.burnInboxMessage(item, done: done)
}
)
}
// 气泡
// 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.groupMemberView.changeGroupBtn.rx.tap.subscribe(onNext: { [weak self] in
self?.showLatestGroupPicker()
}).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.groupMemberView.inviteBtn.rx.tap.subscribe(onNext: { _ in
AppRouter.push(Route.inviteJoin, userInfo: ["groupInfo": self.viewModel.groupInfo])
}).disposed(by: disposeBag)
// 今日轨迹二级页
rootView.groupMemberView.todayTrackView.rx.tapGesture.subscribe(onNext: { [weak self] _ in
self?.openTodayTrackDetail()
}).disposed(by: disposeBag)
// 实时互动
rootView.groupMemberView.emojiCard.rx.tapGesture.subscribe(onNext: { [weak self] _ in
self?.openEmojiPopView()
}).disposed(by: disposeBag)
rootView.groupMemberView.messageCard.rx.tapGesture.subscribe(onNext: { [weak self] _ in
self?.openMessagePopView()
}).disposed(by: disposeBag)
// APP 报告二级页
rootView.groupMemberView.appUsageView.rx.tapGesture.subscribe(onNext: { [weak self] _ in
guard let self else { return }
guard !self.rootView.groupMemberView.isAppUsageDenied else { return }
self.openAppUsageReport()
}).disposed(by: disposeBag)
rootView.groupMemberView.onAppUsageRemind = { [weak self] in
self?.remindSelectedMember(content: "请开启App使用记录隐私设置")
}
// 地图回到自己并重新开启自动跟随
rootView.locationView.rx.tapGesture.subscribe { _ in
self.startFollowingCurrentUser()
}.disposed(by: disposeBag)
// User Config刷新
NotificationCenter.default.rx.notification(.RefreshUserConfigNotification, object: nil)
.startWith(Notification(name: .RefreshUserConfigNotification))
.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
self.rootView.groupMemberView.refreshInterceptState()
self.requestUserInfo { [weak self] in
self?.requestGroupInfo()
}
self.requestNotice()
self.refreshPigeonInbox()
}).disposed(by: disposeBag)
// 圈子刷新
NotificationCenter.default.rx.notification(.RefreshGroupInfoNotification, object: nil)
.subscribe { [weak self] notification in
self?.scheduleGroupDataRefresh(change: GroupDataChange(notification: notification))
}.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
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 {
self.selectedMemberId = ""
self.refreshCoupleMemberList([])
self.rootView.setSOSGradientActive(false)
self.rootView.groupMemberView.clearMemberInfo()
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
} else {
self.selectedMemberId = items[0].user_id
}
}
self.refreshCoupleMemberList(items)
self.refreshSelectedMemberInfoIfNeeded(userId: self.selectedMemberId)
self.updateSelectedMemberSOSGradient()
})
.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
private func queryUnlockRequests() {
UserService.phoneUnlockRequests(os: "ios")
.subscribe(onNext: { [weak self] response in
guard let self else { return }
let requested = response.list.filter { $0.requestUnlock == 1 }
self.unlockRequestItems = requested.isEmpty ? response.list : requested
self.rootView.unlockRequestBtn.isHidden = self.unlockRequestItems.isEmpty
}, onError: { [weak self] _ in
self?.rootView.unlockRequestBtn.isHidden = self?.unlockRequestItems.isEmpty ?? true
})
.disposed(by: disposeBag)
}
private func makeUnlockRequestDisplayItems() -> [UnlockRequestDisplayItem] {
unlockRequestItems.map { item in
let member = viewModel.memberList.first { member in
member.user_id == item.targetUserId
}
return UnlockRequestDisplayItem(
member: member,
fallbackName: item.nickName,
headPic: item.headPic,
lockStartTime: item.lockStartDate,
os: item.os,
groupKey: item.groupKey,
userId: item.targetUserId,
tokens: item.tokens
)
}
}
private func refreshPigeonInbox() {
guard Defaults[\.loginToken] != nil else {
inboxMessages = []
rootView.pigeonMsgBtn.isHidden = true
return
}
PigeonService.inbox()
.subscribe(onNext: { [weak self] response in
guard let self else { return }
self.inboxMessages = response.list
self.rootView.pigeonMsgBtn.isHidden = response.list.isEmpty
}, onError: { [weak self] _ in
self?.rootView.pigeonMsgBtn.isHidden = self?.inboxMessages.isEmpty ?? true
})
.disposed(by: disposeBag)
}
private func makeReceiveDisplayItem(_ message: PigeonSentMessage) -> ReceiveMessageDisplayItem {
let localTemplateImage = message.localTemplateImage
let senderId = message.from_user?.user_id ?? ""
let memberRelationIdx = viewModel.memberList.first { $0.user_id == senderId }?
.extra.relation_idx.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let relationIdx = memberRelationIdx.isEmpty
? message.from_user?.extra.relation_idx ?? ""
: memberRelationIdx
return ReceiveMessageDisplayItem(
messageUUID: message.message_uuid,
senderName: message.from_user?.displayName ?? "圈子成员",
avatar: message.from_user?.avatarImage,
headPic: message.from_user?.displayHeadPic ?? "",
image: localTemplateImage,
imageURL: localTemplateImage == nil ? message.backgroundURL : nil,
message: message.captionText,
kind: message.isVoice ? .voice : .image,
duration: message.localVoiceTemplate?.duration ?? 0,
localAudioData: message.localVoiceData,
audioURL: message.audioURL,
relationIdx: relationIdx
)
}
private func burnInboxMessage(_ item: ReceiveMessageDisplayItem, done: @escaping (Bool) -> Void) {
guard !item.messageUUID.isEmpty else {
done(true)
return
}
DLToast.showLoading()
PigeonService.burn(messageUUID: item.messageUUID)
.subscribe(onNext: { [weak self] _ in
DLToast.dismiss()
guard let self else {
done(true)
return
}
self.inboxMessages.removeAll { $0.message_uuid == item.messageUUID }
self.rootView.pigeonMsgBtn.isHidden = self.inboxMessages.isEmpty
done(true)
}, onError: { error in
DLToast.dismiss()
DLToast.show(error)
done(false)
})
.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 scheduleGroupDataRefresh(change: GroupDataChange? = nil) {
if let change {
pendingGroupDataChanges.append(change)
}
groupRefreshWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
guard let self else { return }
let changes = self.pendingGroupDataChanges
self.pendingGroupDataChanges.removeAll()
self.requestGroupInfo(
changes: changes,
retryAllowed: changes.contains(where: \.isMQTT),
showError: false
)
}
groupRefreshWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
}
private func requestGroupInfo(changes: [GroupDataChange] = [],
retryAllowed: Bool = false,
showError: Bool = true) {
let requestID = UUID()
groupInfoRequestID = requestID
GroupService.groupInfo().subscribe(onNext: { [weak self] response in
guard let self,
self.groupInfoRequestID == requestID else { return }
guard let model = response.model else {
if showError {
DLToast.showError(text: response.message ?? "获取圈子信息失败")
}
if retryAllowed {
self.scheduleGroupDataRetry(changes: changes)
}
return
}
self.applyGroupModel(
model,
requestID: requestID,
changes: changes,
retryAllowed: retryAllowed,
showError: showError
)
}, onError: { [weak self] error in
guard let self, self.groupInfoRequestID == requestID else { return }
if showError {
DLToast.showError(error)
}
if retryAllowed {
self.scheduleGroupDataRetry(changes: changes)
}
}).disposed(by: disposeBag)
}
private func applyGroupModel(_ model: GroupModel,
requestID: UUID,
changes: [GroupDataChange],
retryAllowed: Bool,
showError: Bool) {
let previousGroupKey = viewModel.groupModel?.default_group_key ?? ""
let requestedGroupKey = model.default_group_key
let isSwitchingGroup = previousGroupKey != requestedGroupKey
let hasRequestedGroup = model.groups.contains { $0.group_key == requestedGroupKey }
viewModel.groupModel = model
rootView.groupMemberView.groupNameLab.text = viewModel.groupName.isEmpty
? "我的圈子"
: viewModel.groupName
let pickerMembers = isSwitchingGroup ? nil : viewModel.memberList
GroupListPopView.updateIfVisible(
groupModel: model,
currentMembers: pickerMembers,
presentationOwner: self
)
if isSwitchingGroup || requestedGroupKey.isEmpty || !hasRequestedGroup {
clearPhoneUsageState()
viewModel.clearMembers()
syncMemberAnnotations([])
refreshMQTTSubscriptions([])
}
guard !requestedGroupKey.isEmpty, hasRequestedGroup else {
if retryAllowed, shouldRetryGroupList(model: model, changes: changes) {
scheduleGroupDataRetry(changes: changes)
}
return
}
let shouldRetryList = retryAllowed && shouldRetryGroupList(model: model, changes: changes)
requestGroupMembers(
groupKey: requestedGroupKey,
requestID: requestID,
changes: changes,
retryAllowed: retryAllowed && !shouldRetryList,
showError: showError
)
if shouldRetryList {
scheduleGroupDataRetry(changes: changes)
}
}
private func shouldRetryGroupList(model: GroupModel,
changes: [GroupDataChange]) -> Bool {
let currentUserId = AppContextManager.shared.userId
let groupKeys = Set(model.groups.map(\.group_key))
return changes.contains { change in
guard change.isMQTT, !change.groupKey.isEmpty else { return false }
switch change.operation {
case "join":
return (change.userId.isEmpty || change.userId == currentUserId)
&& !groupKeys.contains(change.groupKey)
case "leave", "kick":
return change.userId == currentUserId && groupKeys.contains(change.groupKey)
case "dismiss":
return groupKeys.contains(change.groupKey)
default:
return false
}
}
}
private func shouldRetryMembers(_ members: [GroupMemberModel],
groupKey: String,
changes: [GroupDataChange]) -> Bool {
let memberIds = Set(members.map(\.user_id))
return changes.contains { change in
guard change.isMQTT,
change.groupKey == groupKey,
!change.userId.isEmpty else { return false }
switch change.operation {
case "join":
return !memberIds.contains(change.userId)
case "leave", "kick":
return memberIds.contains(change.userId)
default:
return false
}
}
}
private func scheduleGroupDataRetry(changes: [GroupDataChange]) {
groupDataRetryWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.requestGroupInfo(changes: changes, retryAllowed: false, showError: false)
}
groupDataRetryWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: work)
}
private func showLatestGroupPicker() {
groupDataRetryWorkItem?.cancel()
let cachedModel = viewModel.groupModel
let requestID = UUID()
groupInfoRequestID = requestID
GroupService.groupInfo()
.subscribe(onNext: { [weak self] response in
guard let self, self.groupInfoRequestID == requestID else { return }
guard let model = response.model else {
if let cachedModel {
self.presentGroupPicker(groupModel: cachedModel)
}
return
}
self.applyGroupModel(
model,
requestID: requestID,
changes: [],
retryAllowed: false,
showError: false
)
self.presentGroupPicker(groupModel: model)
}, onError: { [weak self] _ in
guard let self,
self.groupInfoRequestID == requestID,
let cachedModel else { return }
self.presentGroupPicker(groupModel: cachedModel)
})
.disposed(by: disposeBag)
}
private func presentGroupPicker(groupModel: GroupModel) {
GroupListPopView.show(
groupModel: groupModel,
currentMembers: viewModel.memberList,
presentationOwner: self
) { [weak self] groupKey in
guard let self, let groupKey else { return }
self.requestOperateGroup(groupKey: groupKey)
}
}
private func requestGroupMembers(groupKey: String,
requestID: UUID,
changes: [GroupDataChange] = [],
retryAllowed: Bool = false,
showError: Bool = true) {
GroupService.groupUsers(groupKey: groupKey)
.subscribe(onNext: { [weak self] response in
guard let self,
self.groupInfoRequestID == requestID,
self.viewModel.groupModel?.default_group_key == groupKey else { return }
guard response.isValid(for: groupKey) else {
if showError {
DLToast.showError(text: response.message ?? "获取圈子成员失败")
}
if retryAllowed {
self.scheduleGroupDataRetry(changes: changes)
}
return
}
self.viewModel.updateGroupUsers(response.list)
// 成员接口是气泡结束的权威来源:status=0 时重新显示地图点。
self.syncMemberAnnotations(self.viewModel.memberList)
self.refreshMQTTSubscriptions(self.viewModel.memberList)
self.updateOnlineCount()
self.requestSelectedMemberPhoneUsage(groupKey: groupKey)
if let groupModel = self.viewModel.groupModel {
GroupListPopView.updateIfVisible(
groupModel: groupModel,
currentMembers: self.viewModel.memberList,
presentationOwner: self
)
}
if retryAllowed,
self.shouldRetryMembers(response.list, groupKey: groupKey, changes: changes) {
self.scheduleGroupDataRetry(changes: changes)
}
}, onError: { [weak self] error in
guard let self,
self.groupInfoRequestID == requestID,
self.viewModel.groupModel?.default_group_key == groupKey else { return }
if showError {
DLToast.showError(error)
}
if retryAllowed {
self.scheduleGroupDataRetry(changes: changes)
}
})
.disposed(by: disposeBag)
}
private func phoneUsageCacheKey(groupKey: String, userId: String) -> String {
"\(groupKey)|\(userId)"
}
private func clearPhoneUsageState() {
phoneUsageCache.removeAll()
phoneUsageRequestIDs.removeAll()
if !selectedMemberId.isEmpty {
rootView.groupMemberView.updatePhoneUsage(nil, for: selectedMemberId)
}
}
private func requestSelectedMemberPhoneUsage(groupKey: String? = nil) {
let resolvedGroupKey = groupKey ?? viewModel.groupModel?.default_group_key ?? ""
requestPhoneUsage(userId: selectedMemberId, groupKey: resolvedGroupKey)
}
private func requestPhoneUsage(userId: String, groupKey: String) {
guard !userId.isEmpty, !groupKey.isEmpty else { return }
let cacheKey = phoneUsageCacheKey(groupKey: groupKey, userId: userId)
if let cached = phoneUsageCache[cacheKey], selectedMemberId == userId {
rootView.groupMemberView.updatePhoneUsage(cached, for: userId)
}
guard phoneUsageRequestIDs[cacheKey] == nil else { return }
let requestID = UUID()
phoneUsageRequestIDs[cacheKey] = requestID
UserService.phoneUsageToday(userId: userId, groupKey: groupKey)
.subscribe(onNext: { [weak self] response in
guard let self,
self.phoneUsageRequestIDs[cacheKey] == requestID else { return }
self.phoneUsageRequestIDs.removeValue(forKey: cacheKey)
guard self.viewModel.groupModel?.default_group_key == groupKey else { return }
guard response.code == "0", let model = response.model else {
self.phoneUsageCache.removeValue(forKey: cacheKey)
if self.selectedMemberId == userId {
self.rootView.groupMemberView.updatePhoneUsage(nil, for: userId)
}
return
}
self.phoneUsageCache[cacheKey] = model
guard self.selectedMemberId == userId else { return }
self.rootView.groupMemberView.updatePhoneUsage(model, for: userId)
}, onError: { [weak self] _ in
guard let self,
self.phoneUsageRequestIDs[cacheKey] == requestID else { return }
self.phoneUsageRequestIDs.removeValue(forKey: cacheKey)
guard self.viewModel.groupModel?.default_group_key == groupKey else { return }
self.phoneUsageCache.removeValue(forKey: cacheKey)
if self.selectedMemberId == userId {
self.rootView.groupMemberView.updatePhoneUsage(nil, for: userId)
}
})
.disposed(by: disposeBag)
}
private func requestOperateGroup(groupKey: String) {
groupDataRetryWorkItem?.cancel()
if viewModel.groupModel?.default_group_key != groupKey {
groupInfoRequestID = UUID()
clearPhoneUsageState()
viewModel.clearMembers()
syncMemberAnnotations([])
refreshMQTTSubscriptions([])
}
GroupService.operate(opType: "setdefault", requestData: ["group_key" : groupKey])
.subscribe(onNext: { _ in }, onError: { error in
DLToast.showError(error)
})
.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)
// 色点对准坐标,气泡在上方,命中区需向上覆盖整颗标注
let hitWidth: CGFloat = 70
let hitHeight: CGFloat = 110
for ann in rootView.mapView.annotations?.compactMap({ $0 as? MemberAnnotation }) ?? [] {
let pt = rootView.mapView.convert(ann.coordinate, toPointTo: rootView.mapView)
let hitRect = CGRect(
x: pt.x - hitWidth / 2,
y: pt.y - hitHeight + 12,
width: hitWidth,
height: hitHeight
)
if hitRect.contains(point) {
selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false)
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
let currentModel = list.first(where: { $0.user_id == currentUserId })
let lastUpdateTime = currentModel?.last_active_time ?? 0
// 当前用户始终由 GPS 定位,单独注入;头像与成员列表同一份数据
let me = CircleMember(
id: "current",
name: currentModel?.showName ?? AppContextManager.shared.name,
avatar: currentModel?.displayHeadPic ?? AppContextManager.shared.head_pic,
isOnline: true,
isOwner: currentModel.map { isGroupOwner($0.user_id) } ?? false,
coordinate: kCLLocationCoordinate2DInvalid,
address: "",
heading: 0,
lastUpdateTime: lastUpdateTime,
battery: currentModel?.battery ?? ""
)
// 其他成员来自当前圈子的成员接口,过滤掉当前用户。
var others: [CircleMember] = []
let now = Date()
for model in list where model.user_id != currentUserId || currentUserId.isEmpty {
var m = CircleMember(member: model, isOwner: isGroupOwner(model.user_id))
let mqttFresh = lastUpdateTimes[model.user_id].map { now.timeIntervalSince($0) < 60 } ?? false
if mqttFresh, let trueCoord = trueCoordinateByUserId[model.user_id],
CLLocationCoordinate2DIsValid(trueCoord) {
m = CircleMember(
id: m.id, name: m.name, avatar: m.avatar,
isOnline: true, isOwner: m.isOwner,
coordinate: trueCoord, address: m.address,
heading: m.heading, lastUpdateTime: m.lastUpdateTime,
battery: m.battery
)
}
guard CLLocationCoordinate2DIsValid(m.coordinate) else { continue }
others.append(m)
}
let newMembers = others + [me]
members = newMembers
currentUserMember = me
refreshCurrentUserAnnotationAvatar(me)
// 从 members 中过滤出在线成员用于地图标注(当前用户坐标无效,由 GPS 回调单独添加)
let onlineMembers = newMembers.filter { $0.isOnline && !$0.isCurrentUser && !isMemberInBubble($0.id) }
guard let mapView = rootView.mapView else { return }
let existing = mapView.annotations?.compactMap { $0 as? MemberAnnotation } ?? []
var seen = Set<ObjectIdentifier>()
var allExisting: [MemberAnnotation] = []
for annotation in Array(annotationByUserId.values) + existing {
let key = ObjectIdentifier(annotation)
if seen.insert(key).inserted {
allExisting.append(annotation)
}
}
let onlineIDs = Set(onlineMembers.map { $0.id })
let toRemove = allExisting.filter { annotation in
if annotation === currentUserAnnotation || annotation.member.isCurrentUser || annotation.member.id == "current" {
return false
}
return !onlineIDs.contains(annotation.member.id)
}
if !toRemove.isEmpty {
mapView.removeAnnotations(toRemove)
}
for annotation in toRemove {
annotationByUserId.removeValue(forKey: annotation.member.id)
if annotation === currentUserAnnotation || annotation.member.isCurrentUser {
currentUserAnnotation = nil
}
}
for id in Array(annotationByUserId.keys) where !onlineIDs.contains(id) {
annotationByUserId.removeValue(forKey: id)
}
let toAdd = onlineMembers.filter { annotationByUserId[$0.id] == nil }
let annotations = toAdd.map { MemberAnnotation(member: $0) }
if !annotations.isEmpty {
mapView.addAnnotations(annotations)
annotations.forEach { rememberAnnotation($0) }
}
// 自己的针只等高德 didUpdate 再加,不用苹果 GPS / 缓存点
applyDisplayCoordinates()
refreshAnnotationScreenPositions()
bringAnnotationToFront(userId: selectedMemberId)
#endif
}
#if !targetEnvironment(simulator)
private func refreshCurrentUserAnnotationAvatar(_ me: CircleMember) {
guard let ann = currentUserAnnotation else { return }
let updated = CircleMember(
id: "current",
name: me.name,
avatar: me.avatar,
isOnline: true,
isOwner: me.isOwner,
coordinate: ann.coordinate,
address: me.address,
heading: ann.member.heading,
lastUpdateTime: me.lastUpdateTime,
battery: me.battery
)
ann.member = updated
currentUserMember = updated
if let view = rootView.mapView.view(for: ann) as? MemberAnnotationView {
view.configure(with: updated)
view.updateHeading(currentHeading)
}
}
#endif
private func remindSelectedMember(content: String) {
GroupService.sendPrivilegeRemind(
content: content,
groupKey: viewModel.groupModel?.default_group_key ?? "",
userId: selectedMemberId
)
}
/// 若更新的是当前选中成员,刷新信息卡(位置 / 在线 / 距离等)
private func refreshSelectedMemberInfoIfNeeded(userId: String) {
guard userId == selectedMemberId,
let model = viewModel.memberList.first(where: { $0.user_id == userId }) else { return }
let groupKey = viewModel.groupModel?.default_group_key ?? ""
let usage = phoneUsageCache[phoneUsageCacheKey(groupKey: groupKey, userId: userId)]
rootView.groupMemberView.setupMemberInfo(
model,
isOwner: viewModel.isGroupOwn(id: model.user_id),
phoneUsage: usage
)
}
private func updateSelectedMemberSOSGradient() {
let isActive = viewModel.memberList.first(where: { $0.user_id == selectedMemberId })?.status == 1
rootView.setSOSGradientActive(isActive)
}
private func openTodayTrackDetail() {
let members = viewModel.memberList.map { $0.toJSON() }
let userInfo: [String: Any] = [
"members": members,
"userId": selectedMemberId
]
AppRouter.push(Route.todayTrackDetail, userInfo: userInfo)
}
private func openEmojiPopView() {
AddEmojiPopView.show { [weak self] name in
guard let self, let name else { return }
self.sendRealtimeEmoji(name)
}
}
private func openMessagePopView() {
AddMessagePopView.show { [weak self] index in
guard let self, let index else { return }
self.sendRealtimeMessage(index: index)
}
}
private func sendRealtimeEmoji(_ name: String) {
guard name.hasPrefix("normal_") else { return }
let numStr = name.replacingOccurrences(of: "normal_", with: "")
guard let num = Int(numStr) else { return }
let targetId = selectedMemberId
sendRealtimeEmote(emoteIdx: "1\(num)".integer) { [weak self] in
self?.rootView.groupMemberView.applyEmojiEcho(name: name, for: targetId)
}
}
private func sendRealtimeMessage(index: Int) {
let texts = QuickMessageView.messageList
guard texts.indices.contains(index) else { return }
let targetId = selectedMemberId
sendRealtimeEmote(emoteIdx: "3\(index)".integer) { [weak self] in
self?.rootView.groupMemberView.applyMessageEcho(text: texts[index], for: targetId)
}
}
private func sendRealtimeEmote(emoteIdx: Int, onSuccess: @escaping () -> Void) {
guard selectedMemberId != AppContextManager.shared.userId else {
DLToast.show(text: "不能给自己发送")
return
}
guard let model = viewModel.groupModel else { return }
DLToast.showLoading()
UserService.sendEmote(
emoteIdx: emoteIdx,
groupKey: model.default_group_key,
targetUid: selectedMemberId
).subscribe(onNext: { _ in
DLToast.show(text: "发送成功")
onSuccess()
}, onError: { _ in }).disposed(by: disposeBag)
}
private func applyIncomingEmote(from userId: String, emojiName: String?, message: String?) {
if let emojiName {
rootView.groupMemberView.applyEmojiEcho(name: emojiName, for: userId)
ReceivedEmojiPopView.show(name: emojiName)
}
if let message {
rootView.groupMemberView.applyMessageEcho(text: message, for: userId)
ReceivedMessagePopView.showIncoming(
nickName: viewModel.getUserNickName(id: userId),
message: message
)
}
}
private func openAppUsageReport() {
let members = viewModel.memberList.map { $0.toJSON() }
var userInfo: [String: Any] = [
"members": members,
"userId": selectedMemberId
]
if let group = viewModel.groupModel {
userInfo["groupJson"] = group.toJSON()
}
AppRouter.push(Route.appUsageReport, userInfo: userInfo)
}
/// 选中成员:同步列表高亮 / 信息卡,并可选居中地图、收起面板
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
updateSelectedMemberSOSGradient()
refreshSelectedMemberInfoIfNeeded(userId: listUserId)
requestSelectedMemberPhoneUsage()
memberCV.reloadData()
refreshCoupleMemberList(viewModel.memberList)
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 dismissPanel {
// rootView.dismissGroupMemberView()
// }
if centerMap {
let isSelf = userId == "current" || viewModel.isCurrentUser(id: userId)
if !isSelf {
stopFollowingCurrentUser()
}
centerMapOnMember(userId: userId)
}
#if !targetEnvironment(simulator)
bringAnnotationToFront(userId: listUserId, restack: true)
#endif
}
private func refreshCoupleMemberList(_ members: [GroupMemberModel]) {
let showCoupleMembers = viewModel.isCoupleGroup && members.count >= 2
let memberView = rootView.groupMemberView
memberView.setCoupleMode(showCoupleMembers)
memberView.configureCoupleMembers(
showCoupleMembers ? members : [],
selectedId: selectedMemberId,
currentUserId: AppContextManager.shared.userId
)
}
private func startFollowingCurrentUser() {
isAutoFollowingUser = true
#if !targetEnvironment(simulator)
if let ann = currentUserAnnotation {
selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false)
} else if let coordinate = lastLocation?.coordinate, CLLocationCoordinate2DIsValid(coordinate) {
rootView.mapView.setCenter(coordinate, animated: true)
rootView.mapView.setZoomLevel(16, animated: true)
}
rootView.mapView.setUserTrackingMode(.follow, animated: true)
#endif
}
private func stopFollowingCurrentUser() {
guard isAutoFollowingUser else { return }
isAutoFollowingUser = false
#if !targetEnvironment(simulator)
rootView.mapView.setUserTrackingMode(.none, animated: false)
#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 {
/// 切换圈子时刷新 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
}
private func querySelfLocked() {
let userId = AppContextManager.shared.userId.trimmed
let lockStateRevision = AppUnlockCoordinator.shared.currentLockStateRevision()
UserService.phoneLocked(os: "ios")
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] response in
guard let locks = AppUnlockCoordinator.shared.locksForRestoration(
response.model?.locks ?? [],
startedAt: lockStateRevision,
userId: userId
) else { return }
guard !locks.isEmpty else { return }
self?.applyIncomingLocks(locks)
})
.disposed(by: disposeBag)
}
private func unlockMember(_ item: UnlockRequestDisplayItem, done: @escaping (Bool) -> Void) {
let os = item.os.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "ios" : item.os
guard !item.groupKey.isEmpty, !item.userId.isEmpty, !item.tokens.isEmpty else {
DLToast.show(text: "解锁数据缺失")
done(false)
return
}
DLToast.showLoading()
UserService.phoneUnlock(
os: os,
groupKey: item.groupKey,
userId: item.userId,
tokens: item.tokens
)
.subscribe(onNext: { [weak self] _ in
DLToast.show(text: "已解锁")
if item.userId == AppContextManager.shared.userId {
AppUnlockCoordinator.shared.applyUnlock(tokens: item.tokens)
}
self?.queryUnlockRequests()
done(true)
}, onError: { error in
DLToast.show(error, fallbackText: "解锁失败")
done(false)
})
.disposed(by: disposeBag)
}
private func applyIncomingLocks(_ locks: [PhoneLockRecord]) {
PhoneLockSession.currentLocks = locks
guard let first = locks.first else { return }
let tokens = locks.flatMap(\.tokens)
if #available(iOS 16.0, *) {
AppRestrictManager.shared.applyRemoteLock(
tokens: tokens,
iconIndex: first.iconIndex,
message: first.message,
groupName: first.displayGroupName
)
}
LockedAppPopView.show(first)
}
// 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()
self.refreshSelectedMemberInfoIfNeeded(userId: userId)
// 自己的地图点由 GPS 维护(id = "current"),MQTT 回环不再加点,避免叠两个自己
if userId == AppContextManager.shared.userId { return }
// 经纬度无变化跳过地图标注更新
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 self.isMemberInBubble(userId) {
self.removeAnnotation(userId: userId)
} else 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()
refreshSelectedMemberInfoIfNeeded(userId: userId)
case "lockApp":
guard let body = msg.data?.lock_app else { break }
let record = PhoneLockRecord(mqtt: body)
let selfId = AppContextManager.shared.userId
let isSelf = (!record.userId.isEmpty && record.userId == selfId)
|| userId == selfId
DispatchQueue.main.async { [weak self] in
guard isSelf else { return }
AppUnlockCoordinator.shared.registerIncomingLock(tokens: record.tokens)
self?.applyIncomingLocks([record])
}
case "emote": // 快捷消息、表情
guard let targetUserId = msg.data?.user_id,
targetUserId == AppContextManager.shared.userId, // 只接收发给我的
let index = msg.data?.index, index > 9,
let groupKey = msg.data?.group_key,
groupKey.components(separatedBy: "/").count >= 2 else { break }
let firstChar = index.string.prefix(1)
let lastChar = String(index.string.suffix(index.string.count-1))
let emoteUserId = userId
if firstChar == "1" || firstChar == "2" { // 表情
let emojiFileName = firstChar == "1" ? "normal_\(lastChar)" : "fun_\(lastChar)"
DispatchQueue.main.async { [weak self] in
self?.applyIncomingEmote(from: emoteUserId, emojiName: emojiFileName, message: nil)
}
}
else if firstChar == "3" { // 文字
let textIdx = lastChar.integer
let texts = QuickMessageView.messageList
guard textIdx < texts.count else { break }
DispatchQueue.main.async { [weak self] in
self?.applyIncomingEmote(from: emoteUserId, emojiName: nil, message: texts[textIdx])
}
}
case "bubble": // 气泡
guard let userId = msg.data?.user_id else { return }
DispatchQueue.main.async { [weak self] in
self?.viewModel.markMemberInBubble(userId: userId)
self?.removeAnnotation(userId: userId)
}
case "bubblequit":
let targetId = msg.data?.user_id ?? userId
guard !targetId.isEmpty else { return }
DispatchQueue.main.async { [weak self] in
self?.handleBubbleQuit(userId: targetId)
}
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 let member = self.viewModel.memberList.first(where: { $0.user_id == userId }) else { return }
self.restoreAnnotationForSOS(member: member)
self.showSOSReceiveAlert(for: member)
}
case "sosquit":
let targetId = msg.data?.user_id ?? userId
guard !targetId.isEmpty else { return }
DispatchQueue.main.async { [weak self] in
self?.handleSOSQuit(userId: targetId)
}
case "join", "leave", "dismiss", "kick":
break
default:
print("📩 未处理 type=\(msg.type ?? "")")
}
}
private func handleSOSQuit(userId: String) {
viewModel.updateMemberStatus(userId: userId, status: 0)
updateSelectedMemberSOSGradient()
refreshSelectedMemberInfoIfNeeded(userId: userId)
guard let alertView = sosReceiveAlertView, alertView.superview != nil else { return }
if alertView.removeMember(userId: userId) { return }
alertView.dismiss()
if sosReceiveAlertView === alertView {
sosReceiveAlertView = nil
}
stopSOSAlarm()
}
private func handleBubbleQuit(userId: String) {
viewModel.updateMemberStatus(userId: userId, status: 0)
refreshSelectedMemberInfoIfNeeded(userId: userId)
syncMemberAnnotations(viewModel.memberList)
if viewModel.isCurrentUser(id: userId) {
if let coord = trueCoordinateByUserId["current"] ?? trueCoordinateByUserId[userId],
CLLocationCoordinate2DIsValid(coord) {
updateAnnotation(
userId: "current",
coordinate: coord,
address: currentUserMember?.address ?? ""
)
}
return
}
if let coord = trueCoordinateByUserId[userId], CLLocationCoordinate2DIsValid(coord) {
let address = viewModel.memberList.first(where: { $0.user_id == userId })?.lastLocation ?? ""
updateAnnotation(userId: userId, coordinate: coord, address: address)
}
}
private func restoreAnnotationForSOS(member: GroupMemberModel) {
let mapMember = members.first(where: { $0.id == member.user_id })
let (lastCoordinate, lastAddress) = CircleMember.parsePosition(member.last_position)
let coordinate = [
trueCoordinateByUserId[member.user_id],
mapMember?.coordinate,
lastCoordinate
]
.compactMap { $0 }
.first(where: CLLocationCoordinate2DIsValid)
guard let coordinate else { return }
let mapAddress = mapMember?.address ?? ""
let address = mapAddress.isEmpty ? lastAddress : mapAddress
updateAnnotation(userId: member.user_id, coordinate: coordinate, address: address)
}
/// 更新成员在线计数显示
private func updateOnlineCount() {
let total = viewModel.memberCount
let online = viewModel.memberList.filter { $0.is_online }.count
// rootView.groupMemberView.setupCountData(total, online)
}
#if !targetEnvironment(simulator)
private func rememberAnnotation(_ annotation: MemberAnnotation) {
annotationByUserId[annotation.member.id] = annotation
if annotation.member.isCurrentUser {
currentUserAnnotation = annotation
}
}
/// 加点后按当前 view 尺寸重投;follow 延后到下一帧再开,避免动画冻住旧投影
private func refreshAnnotationScreenPositions() {
guard let mapView = rootView.mapView else { return }
let shouldResumeFollow = isAutoFollowingUser
if mapView.userTrackingMode != .none {
mapView.setUserTrackingMode(.none, animated: false)
}
for annotation in mapView.annotations ?? [] {
guard let memberAnn = annotation as? MemberAnnotation else { continue }
let coord = memberAnn.coordinate
memberAnn.coordinate = coord
if let view = mapView.view(for: memberAnn) as? MemberAnnotationView {
view.applyAnchor()
}
}
mapView.setCenter(mapView.centerCoordinate, animated: false)
guard shouldResumeFollow else { return }
DispatchQueue.main.async { [weak self] in
guard let self, self.isAutoFollowingUser else { return }
self.rootView.mapView.setUserTrackingMode(.follow, animated: false)
}
}
private func metersBetween(_ a: CLLocationCoordinate2D, _ b: CLLocationCoordinate2D) -> CLLocationDistance {
CLLocation(latitude: a.latitude, longitude: a.longitude)
.distance(from: CLLocation(latitude: b.latitude, longitude: b.longitude))
}
private func offsetCoordinate(_ coord: CLLocationCoordinate2D, meters: CLLocationDistance, bearing: Double) -> CLLocationCoordinate2D {
let earth: CLLocationDistance = 6_378_137
let latRad = coord.latitude * .pi / 180
let dLat = meters * cos(bearing) / earth * 180 / .pi
let dLon = meters * sin(bearing) / (earth * cos(latRad)) * 180 / .pi
return CLLocationCoordinate2D(latitude: coord.latitude + dLat, longitude: coord.longitude + dLon)
}
/// 20 米内的针绕真实点散开;自己钉在 GPS,别人往外偏
private func applyDisplayCoordinates() {
guard let mapView = rootView.mapView else { return }
let annotations = (mapView.annotations ?? []).compactMap { $0 as? MemberAnnotation }
guard !annotations.isEmpty else { return }
struct Pin {
let id: String
let isSelf: Bool
let trueCoord: CLLocationCoordinate2D
let annotation: MemberAnnotation
}
var pins: [Pin] = []
for annotation in annotations {
let isSelf = annotation.member.isCurrentUser
let key = isSelf ? "current" : annotation.member.id
let trueCoord: CLLocationCoordinate2D
if let stored = trueCoordinateByUserId[key], CLLocationCoordinate2DIsValid(stored) {
trueCoord = stored
} else if isSelf, let loc = lastLocation?.coordinate, CLLocationCoordinate2DIsValid(loc) {
trueCoord = loc
} else if let member = members.first(where: { $0.id == annotation.member.id }),
CLLocationCoordinate2DIsValid(member.coordinate) {
trueCoord = member.coordinate
} else {
continue
}
pins.append(Pin(id: key, isSelf: isSelf, trueCoord: trueCoord, annotation: annotation))
}
var assigned = Set<Int>()
for i in pins.indices {
guard !assigned.contains(i) else { continue }
var cluster = [i]
assigned.insert(i)
var k = 0
while k < cluster.count {
let cur = cluster[k]
for j in pins.indices where !assigned.contains(j) {
if metersBetween(pins[cur].trueCoord, pins[j].trueCoord) < overlapClusterRadius {
cluster.append(j)
assigned.insert(j)
}
}
k += 1
}
if cluster.count == 1 {
pins[cluster[0]].annotation.coordinate = pins[cluster[0]].trueCoord
continue
}
let selfIdx = cluster.first(where: { pins[$0].isSelf })
let centroid: CLLocationCoordinate2D = {
if let selfIdx { return pins[selfIdx].trueCoord }
let lat = cluster.map { pins[$0].trueCoord.latitude }.reduce(0, +) / Double(cluster.count)
let lon = cluster.map { pins[$0].trueCoord.longitude }.reduce(0, +) / Double(cluster.count)
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
}()
if let selfIdx {
pins[selfIdx].annotation.coordinate = pins[selfIdx].trueCoord
let others = cluster.filter { !pins[$0].isSelf }.sorted { pins[$0].id < pins[$1].id }
for (i, idx) in others.enumerated() {
let angle = 2 * Double.pi * Double(i) / Double(max(others.count, 1)) - Double.pi / 2
pins[idx].annotation.coordinate = offsetCoordinate(centroid, meters: overlapSpreadRadius, bearing: angle)
}
} else {
let ordered = cluster.sorted { pins[$0].id < pins[$1].id }
for (i, idx) in ordered.enumerated() {
let angle = 2 * Double.pi * Double(i) / Double(ordered.count) - Double.pi / 2
pins[idx].annotation.coordinate = offsetCoordinate(centroid, meters: overlapSpreadRadius, bearing: angle)
}
}
}
}
private func isSelectedAnnotation(_ annotation: MemberAnnotation, userId: String) -> Bool {
let ids = annotationIds(matching: userId)
return ids.contains(annotation.member.id)
|| (annotation.member.isCurrentUser && (ids.contains("current") || ids.contains(AppContextManager.shared.userId)))
}
private func raiseAnnotationView(_ view: MAAnnotationView) {
view.zIndex = 10
var current: UIView = view
while let superview = current.superview, !(superview is MAMapView) {
superview.bringSubviewToFront(current)
current = superview
}
current.superview?.bringSubviewToFront(current)
}
/// AMap 的 zIndex 只在 viewFor / didAdd 时生效;GPS 刷新自己的点还会把层级打乱。
/// restack=true 时先删再加,保证选中针在最上层。
private func bringAnnotationToFront(userId: String, restack: Bool = false) {
guard let mapView = rootView.mapView, !userId.isEmpty else { return }
var selectedAnn: MemberAnnotation?
for annotation in mapView.annotations ?? [] {
guard let memberAnn = annotation as? MemberAnnotation else { continue }
let selected = isSelectedAnnotation(memberAnn, userId: userId)
if selected { selectedAnn = memberAnn }
if let view = mapView.view(for: memberAnn) as? MemberAnnotationView {
if selected {
view.zIndex = 10
} else if memberAnn.member.isCurrentUser {
view.zIndex = 1
} else {
view.zIndex = 0
}
}
}
guard let selectedAnn else { return }
if restack {
mapView.removeAnnotation(selectedAnn)
mapView.addAnnotation(selectedAnn)
rememberAnnotation(selectedAnn)
}
if let view = mapView.view(for: selectedAnn) {
raiseAnnotationView(view)
}
refreshAnnotationMoods()
}
private func moodIndex(for member: CircleMember) -> Int {
if member.isCurrentUser {
return AppContextManager.shared.account?.mood ?? 0
}
return viewModel.memberList.first(where: { $0.user_id == member.id })?.mood ?? 0
}
private func refreshAnnotationMoods() {
guard let mapView = rootView.mapView else { return }
for annotation in mapView.annotations ?? [] {
guard let memberAnn = annotation as? MemberAnnotation,
let view = mapView.view(for: memberAnn) as? MemberAnnotationView else { continue }
view.setMood(
index: moodIndex(for: memberAnn.member),
selected: isSelectedAnnotation(memberAnn, userId: selectedMemberId)
)
}
}
#endif
/// 当前用户地图点 id 是 "current",MQTT 里是真实 user_id
private func annotationIds(matching userId: String) -> Set<String> {
var ids: Set<String> = [userId]
if userId == "current" || viewModel.isCurrentUser(id: userId) {
ids.insert("current")
let mine = AppContextManager.shared.userId
if !mine.isEmpty {
ids.insert(mine)
}
}
return ids
}
private func isMemberInBubble(_ userId: String) -> Bool {
viewModel.isInBubble(userId: userId)
}
/// 移除指定成员的标注
private func removeAnnotation(userId: String) {
#if !targetEnvironment(simulator)
guard let mapView = rootView.mapView else { return }
let ids = annotationIds(matching: userId)
var toRemove: [MemberAnnotation] = []
var seen = Set<ObjectIdentifier>()
func append(_ annotation: MemberAnnotation?) {
guard let annotation else { return }
let key = ObjectIdentifier(annotation)
guard !seen.contains(key) else { return }
seen.insert(key)
toRemove.append(annotation)
}
for id in ids {
append(annotationByUserId[id])
}
if let current = currentUserAnnotation,
ids.contains(current.member.id) || (current.member.isCurrentUser && ids.contains("current")) {
append(current)
}
for annotation in mapView.annotations?.compactMap({ $0 as? MemberAnnotation }) ?? [] {
if ids.contains(annotation.member.id) {
append(annotation)
}
}
if !toRemove.isEmpty {
mapView.removeAnnotations(toRemove)
}
for id in ids {
annotationByUserId.removeValue(forKey: id)
}
if ids.contains("current")
|| ids.contains(where: { viewModel.isCurrentUser(id: $0) })
|| toRemove.contains(where: { $0 === currentUserAnnotation || $0.member.isCurrentUser }) {
currentUserAnnotation = nil
annotationByUserId.removeValue(forKey: "current")
}
#endif
}
/// 更新指定成员的标注位置(用 MQTT 坐标,不依赖接口 last_position)
private func updateAnnotation(userId: String, coordinate: CLLocationCoordinate2D, address: String) {
#if !targetEnvironment(simulator)
if isMemberInBubble(userId) {
removeAnnotation(userId: userId)
return
}
guard CLLocationCoordinate2DIsValid(coordinate) else { return }
trueCoordinateByUserId[userId] = coordinate
let memberToShow: CircleMember? = {
if let idx = members.firstIndex(where: { $0.id == userId }) {
let old = members[idx]
let updated = 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
)
members[idx] = updated
return updated
}
guard let model = viewModel.memberList.first(where: { $0.user_id == userId }) else { return nil }
let created = CircleMember(
id: model.user_id,
name: model.showName,
avatar: model.displayHeadPic,
isOnline: true,
isOwner: viewModel.isGroupOwn(id: model.user_id),
coordinate: coordinate,
address: address,
heading: 0,
lastUpdateTime: model.last_active_time,
battery: model.battery
)
members.append(created)
return created
}()
guard let member = memberToShow, let mapView = rootView.mapView else { return }
if let existing = annotationByUserId[userId] {
existing.coordinate = coordinate
} else {
let newAnn = MemberAnnotation(member: member)
mapView.addAnnotation(newAnn)
rememberAnnotation(newAnn)
}
applyDisplayCoordinates()
bringAnnotationToFront(userId: selectedMemberId)
#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.member.isCurrentUser
? "MemberAnnotation.current"
: "MemberAnnotation.member"
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? MemberAnnotationView
if annotationView == nil {
annotationView = MemberAnnotationView(annotation: memberAnnotation, reuseIdentifier: identifier)
} else {
annotationView?.annotation = memberAnnotation
}
let selectedIds = annotationIds(matching: selectedMemberId)
let isSelected = selectedIds.contains(memberAnnotation.member.id)
|| (memberAnnotation.member.isCurrentUser
&& (selectedIds.contains("current") || selectedIds.contains(AppContextManager.shared.userId)))
annotationView?.configure(with: memberAnnotation.member)
annotationView?.setMood(
index: moodIndex(for: memberAnnotation.member),
selected: isSelected
)
annotationView?.onTap = { [weak self] ann in
self?.selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false)
}
rememberAnnotation(memberAnnotation)
if memberAnnotation.member.isCurrentUser {
annotationView?.updateHeading(currentHeading)
}
if isSelected {
annotationView?.zIndex = 10
} else if memberAnnotation.member.isCurrentUser {
annotationView?.zIndex = 1
} else {
annotationView?.zIndex = 0
}
return annotationView
}
func mapView(_ mapView: MAMapView!, didAddAnnotationViews views: [Any]!) {
for case let view as MemberAnnotationView in views ?? [] {
view.applyAnchor()
}
}
func mapView(_ mapView: MAMapView!, didSelect view: MAAnnotationView!) {
guard let ann = view.annotation as? MemberAnnotation else { return }
mapView.deselectAnnotation(ann, animated: false)
selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false)
}
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)
// 地图标注
let isFirstFix = lastLocation == nil
lastLocation = location
if isFirstFix {
reportCurrentLocationIfNeeded()
}
Defaults[\.currentLatitude] = location.coordinate.latitude
Defaults[\.currentLongitude] = location.coordinate.longitude
let coordinate = location.coordinate
guard CLLocationCoordinate2DIsValid(coordinate) else { return }
trueCoordinateByUserId["current"] = coordinate
if isMemberInBubble("current") {
removeAnnotation(userId: "current")
} else if let ann = currentUserAnnotation {
ann.coordinate = coordinate
applyDisplayCoordinates()
bringAnnotationToFront(userId: selectedMemberId)
} 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)
rememberAnnotation(annotation)
applyDisplayCoordinates()
bringAnnotationToFront(userId: selectedMemberId)
}
if isAutoFollowingUser, mapView.userTrackingMode != .follow {
mapView.setUserTrackingMode(.follow, animated: false)
}
}
func mapView(_ mapView: MAMapView!, mapWillMoveByUser wasUserAction: Bool) {
guard wasUserAction else { return }
stopFollowingCurrentUser()
}
}
// 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 showSOSReceiveAlert(for member: GroupMemberModel) {
if let alertView = sosReceiveAlertView, alertView.superview != nil {
alertView.appendSecondary(member)
playSOSAlarmIfNeeded()
return
}
guard let window = UIApplication.keyWindow else { return }
let alertView = SOSReceiveAlertView(frame: window.bounds)
alertView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
alertView.onAcknowledged = { [weak self, weak alertView] in
guard let self else { return }
alertView?.dismiss()
if self.sosReceiveAlertView === alertView {
self.sosReceiveAlertView = nil
}
self.stopSOSAlarm()
}
window.addSubview(alertView)
sosReceiveAlertView = alertView
alertView.show(primary: member)
playSOSAlarmIfNeeded()
}
private func playSOSAlarmIfNeeded() {
guard !isSOSPlaying else { return }
// 停止上一次报警
sosAudioStopWorkItem?.cancel()
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
isSOSPlaying = true
startSOSVibration()
let stopWork = DispatchWorkItem { [weak self] in
self?.stopSOSAlarm()
}
sosAudioStopWorkItem = stopWork
DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: stopWork)
}
private func stopSOSAlarm() {
sosAudioStopWorkItem?.cancel()
sosAudioStopWorkItem = nil
stopSOSVibration()
sosPlayer?.stop()
sosPlayer = nil
isSOSPlaying = false
}
private func startSOSVibration() {
stopSOSVibration()
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now() + 1, repeating: 1)
timer.setEventHandler { [weak self] in
guard self?.isSOSPlaying == true else { return }
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
}
sosVibrationTimer = timer
timer.resume()
}
private func stopSOSVibration() {
sosVibrationTimer?.setEventHandler {}
sosVibrationTimer?.cancel()
sosVibrationTimer = nil
}
}
#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