jsdw_ios/QuickLocation/Section/Home/HomeViewController.swift

1622 lines
66 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 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 var popupQueue: PopupQueueManager?
private lazy var mockUnlockStartTimes: [Date] = [
Date().addingTimeInterval(-(11 * 3600 + 11 * 60)),
Date().addingTimeInterval(-(3 * 3600 + 28 * 60)),
Date().addingTimeInterval(-(25 * 60))
]
private static var sosPlayerKey: UInt8 = 0
private var groupRefreshWorkItem: DispatchWorkItem?
private var groupSwitchWorkItem: DispatchWorkItem?
override func loadView() {
#if !targetEnvironment(simulator)
MAMapView.updatePrivacyAgree(.didAgree)
MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain)
#endif
rootView = HomeView2(frame: UIScreen.main.bounds)
view = rootView
}
override func viewDidLoad() {
super.viewDidLoad()
bindViewModel()
setupMap()
setupHeading()
reactiveAction()
// rootView.quickMessageView.tagListView.delegate = self
// MQTT
UIDevice.current.isBatteryMonitoringEnabled = true
startLocationTimer()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
UnlockCountManager.shared.refreshAuthorizationAndMonitoring()
let manager = AuthorizeManager.manager(type: .locationAlways)
rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// sosPlayer?.stop()
}
// MARK: - MQTT
private let geocoder = CLGeocoder()
private var lastGeocodedCoord: CLLocationCoordinate2D?
private var lastAddress: String = ""
private func startLocationTimer() {
locationTimer?.invalidate()
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 = placemarks?.first?.name
?? placemarks?.first?.thoroughfare
?? placemarks?.first?.locality
?? ""
DispatchQueue.main.async {
guard let self else { return }
if !address.isEmpty {
self.lastAddress = address
}
self.lastGeocodedCoord = coord
}
}
}
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.onUnlockRequestTap = { [weak self] in
guard let self else { return }
let requests = self.makeMockUnlockRequests()
UnlockRequestPopView.show(
requests: requests,
onUnlock: { _ in
// AppRestrict / API
},
onReject: { _ in
// API
}
)
}
rootView.onUnlockReceiveTap = { [weak self] in
guard let self else { return }
ReceiveMessagePopView.show(items: self.makeMockReceiveMessages()) { _ in
//
}
}
//
// 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
guard let self = self, let groupModel = self.viewModel.groupModel else { return }
GroupListPopView.show(groupModel: groupModel) { [weak self] groupKey in
guard let self = self, let key = groupKey else { return }
self.requestOperateGroup(groupKey: key)
}
}).disposed(by: disposeBag)
//
// rootView.groupView.rx.tapGesture.subscribe { _ in
// guard let groupModel = self.viewModel.groupModel else { return }
// let groupViewFrame = self.view.convert(self.rootView.groupView.frame, from: self.rootView)
// let startPointY = groupViewFrame.origin.y + groupViewFrame.height
// GroupListPopView.show(start: CGPoint(x: 0, y: startPointY + 20),
// groupModel: groupModel) { groupKey in
// guard let key = groupKey else { return }
// self.requestOperateGroup(groupKey: key)
// }
// }.disposed(by: disposeBag)
//
// rootView.groupMemberView.refreshBtn.rx.tap.subscribe(onNext: { _ in
// self.requestGroupInfo()
// }).disposed(by: disposeBag)
//
rootView.inviteBtn.rx.tap.subscribe(onNext: { _ in
AppRouter.push(Route.inviteJoin, userInfo: ["groupInfo": self.viewModel.groupInfo])
}).disposed(by: disposeBag)
rootView.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)
//
rootView.groupMemberView.phoneReportView.rx.tapGesture.subscribe(onNext: { [weak self] _ in
self?.openPhoneReportDetail()
}).disposed(by: disposeBag)
//
rootView.locationView.rx.tapGesture.subscribe { _ in
self.startFollowingCurrentUser()
}.disposed(by: disposeBag)
// User Config
NotificationCenter.default.rx.notification(.RefreshUserConfigNotification, object: nil)
.startWith(Notification(name: .RefreshUserConfigNotification))
.do(onNext: { [weak self] _ in
self?.popupQueue = nil
})
.flatMapLatest { _ in
SystemService.userConfig()
}
.subscribe(onNext: { [weak self] response in
guard let self = self, let model = response.model else { return }
Defaults[\.loginToken] = model.token
AppContextManager.shared.systemConfig = model.config
// rootView.searchLottieView.isHidden = model.config?.isIntercept == true
self.getUserIMToken()
self.requestUserInfo { [weak self] in
self?.requestGroupInfo()
}
self.requestNotice()
self.popupQueue = nil
self.popupQueue = PopupQueueManager()
self.popupQueue?.start(from: self)
}).disposed(by: disposeBag)
//
NotificationCenter.default.rx.notification(.RefreshGroupInfoNotification, object: nil)
.subscribe { [weak self] notification in
self?.requestGroupInfo()
}.disposed(by: disposeBag)
//
NotificationCenter.default.rx.notification(.ShowMemberLocationNotification, object: nil)
.subscribe { [weak self] notification in
guard let self = self,
let userInfo = notification.userInfo,
let last_position = userInfo["last_position"] as? String,
let group_key = userInfo["group_key"] as? String else { return }
// "lat:lng:address"
let parts = last_position.components(separatedBy: ":")
let coord: CLLocationCoordinate2D?
if parts.count >= 2, let lat = Double(parts[0]), let lng = Double(parts[1]) {
coord = CLLocationCoordinate2D(latitude: lat, longitude: lng)
} else {
coord = nil
}
//
GroupService.operate(opType: "setdefault", requestData: ["group_key": group_key]).subscribe { _ in
self.requestGroupInfo()
if let c = coord, CLLocationCoordinate2DIsValid(c) {
self.rootView.mapView.setCenter(c, animated: true)
self.rootView.mapView.setZoomLevel(16, animated: true)
}
}.disposed(by: self.disposeBag)
}.disposed(by: disposeBag)
//
// rootView.onDismissPanel = { [weak self] in
// self?.isMemberPanelShown = false
// (self?.tabBarController as? MainTabBarController)?.setTabBarHidden(false)
// }
//
// rootView.interactionView.onNavigate = { [weak self] in
// guard let self = self, let member = self.rootView.interactionView.currentMember else { return }
// self.rootView.dismissMemberPanel()
// let userCoord = self.lastLocation?.coordinate
// let groupName = self.viewModel.groupName
// let model = self.viewModel.groupModel
// let iconIndex = model?.groups.first(where: { $0.group_key == model?.default_group_key })?.icon_index ?? 1
// let vc = NavigationVC(member: member, currentUserCoord: userCoord, groupName: groupName, groupIcon: "\(iconIndex)")
// self.navigationController?.pushViewController(vc, animated: true)
// }
//
// rootView.interactionView.onSendEmote = { [weak self] emoteIdx in
// guard let self = self else { return }
// self.requestSendEmote(emoteIdx: emoteIdx)
// }
//
// rootView.interactionView.onShare = {
// SharePopView.show()
// }
}
private func bindViewModel() {
viewModel.output.sectionedItems
.do(onNext: { [weak self] sections in
guard let self = self else { return }
let items = sections.flatMap(\.items)
guard !items.isEmpty else {
self.rootView.setSOSGradientActive(false)
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.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
/// 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 makeMockUnlockRequests() -> [UnlockRequestDisplayItem] {
var candidates = viewModel.memberList
guard !candidates.isEmpty else { return [] }
if let selectedIndex = candidates.firstIndex(where: { $0.user_id == selectedMemberId }) {
let selectedMember = candidates.remove(at: selectedIndex)
candidates.insert(selectedMember, at: 0)
}
while candidates.count < 3 {
candidates.append(contentsOf: candidates)
}
return Array(candidates.prefix(3)).enumerated().map { index, member in
UnlockRequestDisplayItem(
member: member,
lockStartTime: mockUnlockStartTimes[index]
)
}
}
private func makeMockReceiveMessages() -> [ReceiveMessageDisplayItem] {
var candidates = viewModel.memberList
let fallbackImage = UIImage(named: "UserIcon/1")
let mockMessages = [
"想对你说的话都藏在这里,请查收呀",
"今天也要开开心心,注意安全哦"
]
guard !candidates.isEmpty else {
return [
ReceiveMessageDisplayItem(
senderName: "圈子成员",
avatar: fallbackImage,
image: fallbackImage,
message: mockMessages[0]
),
ReceiveMessageDisplayItem(
senderName: "圈子成员",
avatar: fallbackImage,
image: fallbackImage,
message: mockMessages[1],
kind: .voice,
duration: 10
)
]
}
if let selectedIndex = candidates.firstIndex(where: { $0.user_id == selectedMemberId }) {
let selectedMember = candidates.remove(at: selectedIndex)
candidates.insert(selectedMember, at: 0)
}
while candidates.count < 2 {
candidates.append(contentsOf: candidates)
}
return Array(candidates.prefix(2)).enumerated().map { index, member in
let memberImage = member.userIcon
let displayImage = memberImage.size == .zero ? fallbackImage : memberImage
return ReceiveMessageDisplayItem(
senderName: member.nick_name,
avatar: displayImage,
image: displayImage,
message: mockMessages[index],
kind: index == 1 ? .voice : .image,
duration: index == 1 ? 10 : 0
)
}
}
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)
// status=0
self.syncMemberAnnotations(self.viewModel.memberList)
guard isDefaultGroup else { return }
// self.rootView.groupMemberView.setupCountData(self.viewModel.memberCount, 1)
self.rootView.groupMemberView.groupNameLab.text = self.viewModel.groupName
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)
//
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
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] = []
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
// 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
}
/// / 线 /
private func refreshSelectedMemberInfoIfNeeded(userId: String) {
guard userId == selectedMemberId,
let model = viewModel.memberList.first(where: { $0.user_id == userId }) else { return }
rootView.groupMemberView.setupMemberInfo(
model,
isOwner: viewModel.isGroupOwn(id: model.user_id)
)
}
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 openPhoneReportDetail() {
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.phoneReportDetail, 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)
memberCV.reloadData()
if let idx = viewModel.memberList.firstIndex(where: { $0.user_id == listUserId }) {
let indexPath = IndexPath(item: idx, section: 0)
DispatchQueue.main.async { [weak self] in
guard let self = self,
self.memberCV.numberOfSections > 0,
self.memberCV.numberOfItems(inSection: 0) > idx else { return }
self.memberCV.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
}
}
// if 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 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 {
/// join/leave/dismiss 500ms
private func debounceGroupSwitch(groupKey: String) {
groupSwitchWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.requestOperateGroup(groupKey: groupKey)
}
groupSwitchWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work)
}
private func debounceGroupRefresh() {
groupRefreshWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.requestGroupInfo()
}
groupRefreshWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work)
}
/// MQTT
private func refreshMQTTSubscriptions(_ members: [GroupMemberModel]) {
// let currentId = AppContextManager.shared.userId
let newIds = members.map { $0.user_id }//.filter { $0 != currentId } //
let toUnsub = subscribedMemberIds.filter { !newIds.contains($0) }
MQTTService.shared.unsubscribeGroupMembers(toUnsub)
let toSub = newIds.filter { !subscribedMemberIds.contains($0) }
for memberId in toSub {
let topic = "smartdrive/\(memberId)"
MQTTService.shared.subscribe(topic: topic) { [weak self] message in
self?.handleMemberLocation(topic: message.topic, payload: message.string)
}
}
subscribedMemberIds = newIds
}
// MARK: - MQTT type
private func handleMemberLocation(topic: String, payload: String?) {
print("📩 收到消息 -> 主题:\(topic),内容:\(payload ?? "Unkown")")
guard let payload = payload,
let data = payload.data(using: .utf8),
let msg = try? JSONDecoder().decode(MqttIncomingMessage.self, from: data)
else { return }
let userId = topic.replacingOccurrences(of: "smartdrive/", with: "")
switch msg.type {
case "track":
guard let firstPoint = msg.data?.points?.first else { return }
let coord = CLLocationCoordinate2D(latitude: firstPoint.lat, longitude: firstPoint.lon)
guard CLLocationCoordinate2DIsValid(coord) else { return }
// time 线
let nowMs = Date().timeIntervalSince1970 * 1000
let msgTimeMs = Double(firstPoint.time)
let diffSec = (nowMs - msgTimeMs) / 1000
let isOnline = diffSec < 60
let battery = msg.data?.battery ?? ""
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
//
self.lastUpdateTimes[userId] = Date()
// 线
self.viewModel.updateMemberLocation(
userId: userId, lat: firstPoint.lat, lon: firstPoint.lon,
address: firstPoint.addr, battery: battery,
isOnline: isOnline, lastUpdateTime: firstPoint.time
)
updateOnlineCount()
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 "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_\(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 "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.showSOSReceiveAlert(for: member)
}
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)
}
#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.nick_name,
avatar: model.head_pic,
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