730 lines
28 KiB
Swift
730 lines
28 KiB
Swift
//
|
|
// TodayTrackDetailVC.swift
|
|
// QuickLocation
|
|
//
|
|
|
|
import UIKit
|
|
import RxSwift
|
|
import RxCocoa
|
|
import AMapNaviKit
|
|
import SwiftDate
|
|
import ObjectMapper
|
|
import CoreLocation
|
|
|
|
final class TodayTrackDetailVC: BaseViewController {
|
|
|
|
private var rootView: TodayTrackDetailView!
|
|
private let viewModel: TodayTrackDetailViewModel
|
|
private var routeOverlays: [MAPolyline] = []
|
|
private var panStartHeight: CGFloat = 0
|
|
private var tripList: [ScheduleRecordModel] = []
|
|
private var didScrollDatesToEnd = false
|
|
private var lastDateCVWidth: CGFloat = 0
|
|
private var suppressMapTapHide = false
|
|
|
|
private var playbackPath: [(coordinate: CLLocationCoordinate2D, distance: Double)] = []
|
|
private var playbackTotalDistance: Double = 0
|
|
private var playbackAnnotation: HistoryTrackPlaybackAnnotation?
|
|
private var displayLink: CADisplayLink?
|
|
private var playbackProgress: Double = 0
|
|
private var playbackLastTick: Date?
|
|
private var playbackDuration: TimeInterval = 30
|
|
private let minPlaybackDuration: TimeInterval = 10
|
|
private let maxPlaybackDuration: TimeInterval = 180
|
|
private let playbackReplaySpeed: CLLocationDistance = 80
|
|
|
|
init(members: [GroupMemberModel], selectedUserId: String) {
|
|
self.viewModel = TodayTrackDetailViewModel(members: members, selectedUserId: selectedUserId)
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
convenience init(userInfo: [String: Any]) {
|
|
let rawMembers = userInfo["members"]
|
|
let membersJson: [[String: Any]]
|
|
if let arr = rawMembers as? [[String: Any]] {
|
|
membersJson = arr
|
|
} else if let arr = rawMembers as? [Any] {
|
|
membersJson = arr.compactMap { $0 as? [String: Any] }
|
|
} else {
|
|
membersJson = []
|
|
}
|
|
let members = membersJson.compactMap { GroupMemberModel(JSON: $0) }
|
|
let selected = (userInfo["userId"] as? String) ?? members.first?.user_id ?? ""
|
|
self.init(members: members, selectedUserId: selected)
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
@MainActor deinit {
|
|
displayLink?.invalidate()
|
|
}
|
|
|
|
override func loadView() {
|
|
rootView = TodayTrackDetailView(frame: UIScreen.main.bounds)
|
|
view = rootView
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
rootView.mapView.delegate = self
|
|
rootView.memberCV.dataSource = self
|
|
rootView.memberCV.delegate = self
|
|
rootView.dateCV.dataSource = self
|
|
rootView.dateCV.delegate = self
|
|
rootView.timelineTV.dataSource = self
|
|
rootView.timelineTV.delegate = self
|
|
bindActions()
|
|
bindSheetPan()
|
|
setupPlaybackControls()
|
|
rootView.memberCV.reloadData()
|
|
rootView.dateCV.reloadData()
|
|
scrollToSelectedMember(animated: false)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.rootView.scrollDatesToEnd(animated: false)
|
|
}
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
viewModel.start()
|
|
}
|
|
|
|
override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
let width = rootView.dateCV.bounds.width
|
|
guard width > 0, abs(width - lastDateCVWidth) > 0.5 else { return }
|
|
lastDateCVWidth = width
|
|
rootView.dateCV.collectionViewLayout.invalidateLayout()
|
|
rootView.dateCV.reloadData()
|
|
if !didScrollDatesToEnd {
|
|
didScrollDatesToEnd = true
|
|
rootView.scrollDatesToEnd(animated: false)
|
|
}
|
|
}
|
|
|
|
override func viewDidDisappear(_ animated: Bool) {
|
|
super.viewDidDisappear(animated)
|
|
if isMovingFromParent || isBeingDismissed {
|
|
DLToast.dismiss()
|
|
stopPlayback(resetProgress: false)
|
|
rootView.cleanupMap()
|
|
}
|
|
}
|
|
|
|
private func bindActions() {
|
|
rootView.backBtn.rx.tap
|
|
.subscribe(onNext: { [weak self] in
|
|
self?.navigationController?.popViewController(animated: true)
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
rootView.datePrevBtn.rx.tap
|
|
.subscribe(onNext: { [weak self] in
|
|
self?.rootView.scrollDates(direction: -1)
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
rootView.dateNextBtn.rx.tap
|
|
.subscribe(onNext: { [weak self] in
|
|
self?.rootView.scrollDates(direction: 1)
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
rootView.vipRequiredBtn.rx.tap
|
|
.subscribe(onNext: {
|
|
AppRouter.push(Route.vipRecharge)
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
viewModel.displayedTrips
|
|
.observe(on: MainScheduler.instance)
|
|
.subscribe(onNext: { [weak self] trips in
|
|
self?.drawRoute(trips)
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
viewModel.isEmpty
|
|
.observe(on: MainScheduler.instance)
|
|
.subscribe(onNext: { [weak self] empty in
|
|
guard let self, !self.viewModel.isVipRequired.value else { return }
|
|
self.rootView.emptyLab.isHidden = !empty
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
viewModel.isVipRequired
|
|
.observe(on: MainScheduler.instance)
|
|
.distinctUntilChanged()
|
|
.subscribe(onNext: { [weak self] required in
|
|
guard let self else { return }
|
|
self.stopPlayback(resetProgress: true)
|
|
self.rootView.setVipRequired(required)
|
|
self.updateSheetHeight(isVipRequired: required)
|
|
if !required {
|
|
self.rootView.emptyLab.isHidden = !self.viewModel.isEmpty.value
|
|
}
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
viewModel.trips
|
|
.observe(on: MainScheduler.instance)
|
|
.subscribe(onNext: { [weak self] trips in
|
|
self?.tripList = trips
|
|
self?.rootView.timelineTV.reloadData()
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
viewModel.selectedMemberId
|
|
.observe(on: MainScheduler.instance)
|
|
.subscribe(onNext: { [weak self] _ in
|
|
self?.rootView.memberCV.reloadData()
|
|
self?.scrollToSelectedMember(animated: true)
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
viewModel.selectedDateKey
|
|
.observe(on: MainScheduler.instance)
|
|
.subscribe(onNext: { [weak self] _ in
|
|
self?.rootView.dateCV.reloadData()
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
viewModel.loading
|
|
.observe(on: MainScheduler.instance)
|
|
.distinctUntilChanged()
|
|
.subscribe(onNext: { loading in
|
|
if loading {
|
|
DLToast.showLoading()
|
|
} else {
|
|
DLToast.dismiss()
|
|
}
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
}
|
|
|
|
private func updateSheetHeight(isVipRequired: Bool) {
|
|
guard let constraint = rootView.sheetHeightConstraint else { return }
|
|
let minHeight: CGFloat = 200 + kSafeBottomMargin
|
|
let maxHeight: CGFloat = UIScreen.main.bounds.height * 0.7
|
|
let vipHeight: CGFloat = UIScreen.main.bounds.height * 0.47
|
|
constraint.constant = isVipRequired ? min(maxHeight, max(minHeight, vipHeight)) : minHeight
|
|
}
|
|
|
|
private func bindSheetPan() {
|
|
let pan = UIPanGestureRecognizer()
|
|
rootView.sheetView.addGestureRecognizer(pan)
|
|
pan.rx.event.subscribe(onNext: { [weak self] gesture in
|
|
guard let self, let constraint = self.rootView.sheetHeightConstraint else { return }
|
|
let minH: CGFloat = 200 + kSafeBottomMargin
|
|
let maxH: CGFloat = UIScreen.main.bounds.height * 0.7
|
|
switch gesture.state {
|
|
case .began:
|
|
self.panStartHeight = constraint.constant
|
|
case .changed:
|
|
let dy = gesture.translation(in: self.rootView).y
|
|
constraint.constant = min(maxH, max(minH, self.panStartHeight - dy))
|
|
case .ended, .cancelled:
|
|
let mid = (minH + maxH) / 2
|
|
let target = constraint.constant > mid ? maxH : minH
|
|
UIView.animate(withDuration: 0.25) {
|
|
constraint.constant = target
|
|
self.rootView.layoutIfNeeded()
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}).disposed(by: disposeBag)
|
|
}
|
|
|
|
private func collapseSheetIfExpanded() {
|
|
guard let constraint = rootView.sheetHeightConstraint else { return }
|
|
let minH: CGFloat = 200 + kSafeBottomMargin
|
|
let maxH: CGFloat = UIScreen.main.bounds.height * 0.7
|
|
let mid = (minH + maxH) / 2
|
|
guard constraint.constant > mid else { return }
|
|
UIView.animate(withDuration: 0.25) {
|
|
constraint.constant = minH
|
|
self.rootView.layoutIfNeeded()
|
|
}
|
|
}
|
|
|
|
private func scrollToSelectedMember(animated: Bool) {
|
|
guard let idx = viewModel.members.firstIndex(where: { $0.user_id == viewModel.selectedMemberId.value }),
|
|
viewModel.members.count > 0 else { return }
|
|
let indexPath = IndexPath(item: idx, section: 0)
|
|
guard indexPath.item < rootView.memberCV.numberOfItems(inSection: 0) else { return }
|
|
rootView.memberCV.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: animated)
|
|
}
|
|
|
|
private func setupPlaybackControls() {
|
|
rootView.playBtn.addTarget(self, action: #selector(playButtonTapped), for: .touchUpInside)
|
|
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchDown), for: .touchDown)
|
|
rootView.progressSlider.addTarget(self, action: #selector(progressSliderValueChanged), for: .valueChanged)
|
|
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchUpInside)
|
|
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchUpOutside)
|
|
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchCancel)
|
|
}
|
|
|
|
private func drawRoute(_ trips: [ScheduleRecordModel]) {
|
|
stopPlayback(resetProgress: true)
|
|
rootView.mapView.removeOverlays(routeOverlays)
|
|
routeOverlays.removeAll()
|
|
if let anns = rootView.mapView.annotations {
|
|
rootView.mapView.removeAnnotations(anns)
|
|
}
|
|
playbackAnnotation = nil
|
|
playbackPath = []
|
|
playbackTotalDistance = 0
|
|
|
|
let points = trips.flatMap { $0.trajectory_path }
|
|
let coords = points.compactMap { point -> CLLocationCoordinate2D? in
|
|
let coord = CLLocationCoordinate2D(latitude: point.lat, longitude: point.lng)
|
|
guard abs(point.lat) > 0.0001, abs(point.lng) > 0.0001, CLLocationCoordinate2DIsValid(coord) else {
|
|
return nil
|
|
}
|
|
return coord
|
|
}
|
|
|
|
buildPlaybackPath(coords)
|
|
|
|
if coords.count >= 2 {
|
|
var mutable = coords
|
|
if let polyline = MAPolyline(coordinates: &mutable, count: UInt(mutable.count)) {
|
|
rootView.mapView.add(polyline)
|
|
routeOverlays.append(polyline)
|
|
let padding = UIEdgeInsets(top: 170, left: 40, bottom: 420, right: 40)
|
|
rootView.mapView.showOverlays(routeOverlays, edgePadding: padding, animated: true)
|
|
}
|
|
} else if let first = coords.first {
|
|
rootView.mapView.setCenter(first, animated: true)
|
|
}
|
|
|
|
if let start = coords.first {
|
|
let ann = MAPointAnnotation()
|
|
ann.coordinate = start
|
|
ann.title = "start"
|
|
rootView.mapView.addAnnotation(ann)
|
|
}
|
|
if let end = coords.last {
|
|
let ann = MAPointAnnotation()
|
|
ann.coordinate = end
|
|
ann.title = "end"
|
|
rootView.mapView.addAnnotation(ann)
|
|
}
|
|
|
|
for stay in trips.flatMap({ $0.stay_points }) {
|
|
let coord = CLLocationCoordinate2D(latitude: stay.lat, longitude: stay.lng)
|
|
guard abs(stay.lat) > 0.0001, abs(stay.lng) > 0.0001, CLLocationCoordinate2DIsValid(coord) else {
|
|
continue
|
|
}
|
|
let ann = HistoryTrackStayAnnotation()
|
|
ann.coordinate = coord
|
|
ann.title = "stay"
|
|
ann.minutes = max(0, stay.duration_minutes)
|
|
rootView.mapView.addAnnotation(ann)
|
|
}
|
|
|
|
updatePlaybackAnnotation(progress: 0)
|
|
}
|
|
|
|
private func showStayDuration(at coordinate: CLLocationCoordinate2D, minutes: Int) {
|
|
hideStayDuration()
|
|
let ann = HistoryTrackDurationAnnotation()
|
|
ann.coordinate = coordinate
|
|
ann.title = "duration"
|
|
ann.text = Self.durationBubbleText(minutes)
|
|
rootView.mapView.addAnnotation(ann)
|
|
}
|
|
|
|
private func hideStayDuration() {
|
|
let durations = (rootView.mapView.annotations ?? []).compactMap { $0 as? HistoryTrackDurationAnnotation }
|
|
if !durations.isEmpty {
|
|
rootView.mapView.removeAnnotations(durations)
|
|
}
|
|
}
|
|
|
|
private func buildPlaybackPath(_ coords: [CLLocationCoordinate2D]) {
|
|
var path: [(coordinate: CLLocationCoordinate2D, distance: Double)] = []
|
|
var cumulative: Double = 0
|
|
var last: CLLocationCoordinate2D?
|
|
for coord in coords {
|
|
if let last {
|
|
cumulative += last.distance(to: coord)
|
|
}
|
|
path.append((coordinate: coord, distance: cumulative))
|
|
last = coord
|
|
}
|
|
playbackPath = path
|
|
playbackTotalDistance = cumulative
|
|
}
|
|
|
|
// MARK: - Playback
|
|
|
|
@objc private func playButtonTapped() {
|
|
if !rootView.playBtn.isSelected, playbackProgress >= 1 {
|
|
playbackProgress = 0
|
|
rootView.progressSlider.value = 0
|
|
updatePlaybackAnnotation(progress: playbackProgress)
|
|
}
|
|
rootView.playBtn.isSelected ? pausePlayback() : startPlayback()
|
|
}
|
|
|
|
@objc private func progressSliderTouchDown() {
|
|
pausePlayback()
|
|
}
|
|
|
|
@objc private func progressSliderValueChanged() {
|
|
playbackProgress = Double(rootView.progressSlider.value)
|
|
updatePlaybackAnnotation(progress: playbackProgress)
|
|
}
|
|
|
|
@objc private func progressSliderTouchEnded() {
|
|
playbackProgress = Double(rootView.progressSlider.value)
|
|
updatePlaybackAnnotation(progress: playbackProgress)
|
|
}
|
|
|
|
private func startPlayback() {
|
|
guard playbackPath.count >= 2, playbackProgress < 1 else { return }
|
|
let distance = max(playbackTotalDistance, 1)
|
|
playbackDuration = max(minPlaybackDuration, min(maxPlaybackDuration, distance / playbackReplaySpeed))
|
|
rootView.playBtn.isSelected = true
|
|
playbackLastTick = Date()
|
|
displayLink?.invalidate()
|
|
displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink))
|
|
displayLink?.add(to: .main, forMode: .common)
|
|
}
|
|
|
|
private func pausePlayback() {
|
|
rootView.playBtn.isSelected = false
|
|
displayLink?.invalidate()
|
|
displayLink = nil
|
|
playbackLastTick = nil
|
|
}
|
|
|
|
private func stopPlayback(resetProgress: Bool) {
|
|
pausePlayback()
|
|
if resetProgress {
|
|
playbackProgress = 0
|
|
rootView.progressSlider.value = 0
|
|
}
|
|
}
|
|
|
|
@objc private func handleDisplayLink() {
|
|
let now = Date()
|
|
let delta = playbackLastTick.map { now.timeIntervalSince($0) } ?? 0
|
|
playbackLastTick = now
|
|
playbackProgress = min(1, playbackProgress + delta / playbackDuration)
|
|
rootView.progressSlider.value = Float(playbackProgress)
|
|
updatePlaybackAnnotation(progress: playbackProgress)
|
|
if playbackProgress >= 1 {
|
|
pausePlayback()
|
|
}
|
|
}
|
|
|
|
private func updatePlaybackAnnotation(progress: Double) {
|
|
guard let coordinate = playbackCoordinate(at: progress) else { return }
|
|
if let annotation = playbackAnnotation {
|
|
annotation.coordinate = coordinate
|
|
} else {
|
|
let annotation = HistoryTrackPlaybackAnnotation()
|
|
annotation.coordinate = coordinate
|
|
playbackAnnotation = annotation
|
|
rootView.mapView.addAnnotation(annotation)
|
|
}
|
|
}
|
|
|
|
private func playbackCoordinate(at progress: Double) -> CLLocationCoordinate2D? {
|
|
let clamped = min(max(progress, 0), 1)
|
|
let path = playbackPath
|
|
guard !path.isEmpty else { return nil }
|
|
guard playbackTotalDistance > 0 else { return path.first?.coordinate }
|
|
|
|
let target = playbackTotalDistance * clamped
|
|
var lo = 0, hi = path.count - 1
|
|
while lo < hi {
|
|
let mid = (lo + hi) / 2
|
|
if path[mid].distance < target {
|
|
lo = mid + 1
|
|
} else {
|
|
hi = mid
|
|
}
|
|
}
|
|
let idx = lo
|
|
let prev = idx > 0 ? path[idx - 1] : path[0]
|
|
let curr = path[idx]
|
|
let seg = curr.distance - prev.distance
|
|
let ratio = seg > 0 ? max(0, min(1, (target - prev.distance) / seg)) : 0
|
|
return prev.coordinate.interpolate(to: curr.coordinate, ratio: ratio)
|
|
}
|
|
|
|
private static func stayPointImage() -> UIImage {
|
|
let size = CGSize(width: 14, height: 14)
|
|
UIGraphicsBeginImageContextWithOptions(size, false, 0)
|
|
defer { UIGraphicsEndImageContext() }
|
|
UIColor(hexStr: "#293445").setFill()
|
|
UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill()
|
|
UIColor.white.setFill()
|
|
UIBezierPath(ovalIn: CGRect(x: 4, y: 4, width: 6, height: 6)).fill()
|
|
return UIGraphicsGetImageFromCurrentImageContext() ?? UIImage()
|
|
}
|
|
|
|
private static func durationBubbleText(_ minutes: Int) -> String {
|
|
let hours = minutes / 60
|
|
let mins = minutes % 60
|
|
if hours > 0 {
|
|
return "\(hours)小时\(mins)分"
|
|
}
|
|
return "\(mins)分钟"
|
|
}
|
|
|
|
private static func playbackAvatarImage(_ image: UIImage) -> UIImage? {
|
|
let size = CGSize(width: 36, height: 36)
|
|
UIGraphicsBeginImageContextWithOptions(size, false, 0)
|
|
defer { UIGraphicsEndImageContext() }
|
|
let rect = CGRect(origin: .zero, size: size)
|
|
UIColor.white.setFill()
|
|
UIBezierPath(ovalIn: rect).fill()
|
|
let imageRect = rect.insetBy(dx: 2, dy: 2)
|
|
UIBezierPath(ovalIn: imageRect).addClip()
|
|
image.draw(in: imageRect)
|
|
return UIGraphicsGetImageFromCurrentImageContext()
|
|
}
|
|
}
|
|
|
|
// MARK: - Collection / Table
|
|
|
|
extension TodayTrackDetailVC: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
|
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
|
if collectionView === rootView.memberCV {
|
|
return viewModel.members.count
|
|
}
|
|
return viewModel.dateItems.count
|
|
}
|
|
|
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
|
if collectionView === rootView.memberCV {
|
|
let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath)
|
|
let model = viewModel.members[indexPath.item]
|
|
let selected = model.user_id == viewModel.selectedMemberId.value
|
|
let isMe = model.user_id == AppContextManager.shared.userId
|
|
cell.configure(model: model, isCurrentUser: isMe, isSelected: selected)
|
|
return cell
|
|
}
|
|
let cell: TodayTrackDateCell = collectionView.dequeueReusableCell(for: indexPath)
|
|
let item = viewModel.dateItems[indexPath.item]
|
|
let selected = item.key == viewModel.selectedDateKey.value
|
|
cell.configure(title: item.title, selected: selected)
|
|
return cell
|
|
}
|
|
|
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
|
if collectionView === rootView.memberCV {
|
|
let model = viewModel.members[indexPath.item]
|
|
viewModel.selectedMemberId.accept(model.user_id)
|
|
return
|
|
}
|
|
let item = viewModel.dateItems[indexPath.item]
|
|
viewModel.selectedDateKey.accept(item.key)
|
|
}
|
|
|
|
func collectionView(
|
|
_ collectionView: UICollectionView,
|
|
layout collectionViewLayout: UICollectionViewLayout,
|
|
sizeForItemAt indexPath: IndexPath
|
|
) -> CGSize {
|
|
if collectionView === rootView.memberCV {
|
|
return CGSize(width: 61, height: 90)
|
|
}
|
|
return rootView.dateItemSize()
|
|
}
|
|
}
|
|
|
|
extension TodayTrackDetailVC: UITableViewDataSource, UITableViewDelegate {
|
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
tripList.count
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
let cell: TodayTrackTripCell = tableView.dequeueReusableCell(for: indexPath)
|
|
let trip = tripList[indexPath.row]
|
|
let isFirst = indexPath.row == 0
|
|
let isLast = indexPath.row == tripList.count - 1
|
|
cell.configure(trip: trip, isFirst: isFirst, isLast: isLast)
|
|
cell.onViewTapped = { [weak self] in
|
|
self?.selectTripAndCollapse(trip)
|
|
}
|
|
return cell
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
guard tripList.indices.contains(indexPath.row) else { return }
|
|
selectTripAndCollapse(tripList[indexPath.row])
|
|
}
|
|
|
|
private func selectTripAndCollapse(_ trip: ScheduleRecordModel) {
|
|
viewModel.selectTrip(trip)
|
|
collapseSheetIfExpanded()
|
|
}
|
|
}
|
|
|
|
extension TodayTrackDetailVC: MAMapViewDelegate {
|
|
func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! {
|
|
guard let polyline = overlay as? MAPolyline else { return nil }
|
|
let renderer = MAPolylineRenderer(polyline: polyline)
|
|
renderer?.strokeColor = UIColor(hexStr: "#58EDFF")
|
|
renderer?.lineWidth = 6
|
|
renderer?.lineJoinType = kMALineJoinRound
|
|
renderer?.lineCapType = kMALineCapRound
|
|
return renderer
|
|
}
|
|
|
|
func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! {
|
|
guard !(annotation is MAUserLocation) else { return nil }
|
|
|
|
if annotation is HistoryTrackPlaybackAnnotation {
|
|
let id = "historyTrack.playback"
|
|
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
|
|
if view == nil {
|
|
view = MAAnnotationView(annotation: annotation, reuseIdentifier: id)
|
|
}
|
|
view?.annotation = annotation
|
|
let headPic = viewModel.selectedMember?.displayHeadPic ?? ""
|
|
view?.image = Self.playbackAvatarImage(HeadPic.image(for: headPic))
|
|
HeadPic.load(headPic) { [weak view] image in
|
|
view?.image = Self.playbackAvatarImage(image)
|
|
}
|
|
view?.centerOffset = CGPoint(x: 0, y: -18)
|
|
view?.zIndex = 20
|
|
return view
|
|
}
|
|
|
|
if annotation is HistoryTrackStayAnnotation {
|
|
let id = "historyTrack.stay"
|
|
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
|
|
if view == nil {
|
|
view = MAAnnotationView(annotation: annotation, reuseIdentifier: id)
|
|
}
|
|
view?.annotation = annotation
|
|
view?.image = Self.stayPointImage()
|
|
view?.centerOffset = .zero
|
|
view?.zIndex = 8
|
|
view?.canShowCallout = false
|
|
return view
|
|
}
|
|
|
|
if let duration = annotation as? HistoryTrackDurationAnnotation {
|
|
let id = "historyTrack.duration"
|
|
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id) as? HistoryTrackDurationAnnotationView
|
|
if view == nil {
|
|
view = HistoryTrackDurationAnnotationView(annotation: annotation, reuseIdentifier: id)
|
|
}
|
|
view?.annotation = annotation
|
|
view?.configure(text: duration.text)
|
|
return view
|
|
}
|
|
|
|
guard let pointAnn = annotation as? MAPointAnnotation else { return nil }
|
|
if pointAnn.title == "start" {
|
|
let id = "historyTrack.start"
|
|
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
|
|
if view == nil {
|
|
view = MAAnnotationView(annotation: annotation, reuseIdentifier: id)
|
|
}
|
|
view?.annotation = annotation
|
|
view?.image = UIImage(named: "Home/HistoryTrack/start")
|
|
view?.centerOffset = CGPoint(x: 0, y: -14)
|
|
view?.zIndex = 10
|
|
return view
|
|
}
|
|
if pointAnn.title == "end" {
|
|
let id = "historyTrack.end"
|
|
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
|
|
if view == nil {
|
|
view = MAAnnotationView(annotation: annotation, reuseIdentifier: id)
|
|
}
|
|
view?.annotation = annotation
|
|
view?.image = UIImage(named: "Home/HistoryTrack/end")
|
|
view?.centerOffset = CGPoint(x: 0, y: -14)
|
|
view?.zIndex = 10
|
|
return view
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mapView(_ mapView: MAMapView!, didSelect view: MAAnnotationView!) {
|
|
guard let stay = view.annotation as? HistoryTrackStayAnnotation else { return }
|
|
mapView.deselectAnnotation(stay, animated: false)
|
|
suppressMapTapHide = true
|
|
showStayDuration(at: stay.coordinate, minutes: stay.minutes)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.suppressMapTapHide = false
|
|
}
|
|
}
|
|
|
|
func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) {
|
|
guard !suppressMapTapHide else { return }
|
|
hideStayDuration()
|
|
}
|
|
}
|
|
|
|
private final class HistoryTrackPlaybackAnnotation: MAPointAnnotation {}
|
|
|
|
private final class HistoryTrackStayAnnotation: MAPointAnnotation {
|
|
var minutes: Int = 0
|
|
}
|
|
|
|
private final class HistoryTrackDurationAnnotation: MAPointAnnotation {
|
|
var text: String = ""
|
|
}
|
|
|
|
private final class HistoryTrackDurationAnnotationView: MAAnnotationView {
|
|
private let bubble = UIView()
|
|
private let iconView = UIImageView(image: UIImage(named: "Home/HistoryTrack/clock"))
|
|
private let label = UILabel()
|
|
|
|
override init(annotation: MAAnnotation?, reuseIdentifier: String?) {
|
|
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
|
|
bubble.backgroundColor = .white
|
|
bubble.layer.cornerRadius = 16
|
|
bubble.layer.shadowColor = UIColor.black.withAlphaComponent(0.12).cgColor
|
|
bubble.layer.shadowOpacity = 1
|
|
bubble.layer.shadowOffset = CGSize(width: 0, height: 2)
|
|
bubble.layer.shadowRadius = 6
|
|
addSubview(bubble)
|
|
iconView.contentMode = .scaleAspectFit
|
|
bubble.addSubview(iconView)
|
|
label.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
label.textColor = UIColor(hexStr: "#293445")
|
|
bubble.addSubview(label)
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
func configure(text: String) {
|
|
label.text = text
|
|
let textWidth = ceil((text as NSString).size(withAttributes: [.font: label.font as Any]).width)
|
|
let width = max(96, textWidth + 44)
|
|
bounds = CGRect(x: 0, y: 0, width: width, height: 32)
|
|
centerOffset = CGPoint(x: 0, y: -36)
|
|
bubble.frame = bounds
|
|
iconView.frame = CGRect(x: 10, y: 7, width: 18, height: 18)
|
|
label.frame = CGRect(x: 32, y: 0, width: width - 40, height: 32)
|
|
}
|
|
}
|
|
|
|
private extension CLLocationCoordinate2D {
|
|
func distance(to coordinate: CLLocationCoordinate2D) -> CLLocationDistance {
|
|
let fromLocation = CLLocation(latitude: latitude, longitude: longitude)
|
|
let toLocation = CLLocation(latitude: coordinate.latitude, longitude: longitude)
|
|
return fromLocation.distance(from: toLocation)
|
|
}
|
|
|
|
func interpolate(to coordinate: CLLocationCoordinate2D, ratio: Double) -> CLLocationCoordinate2D {
|
|
let clampedRatio = min(max(ratio, 0), 1)
|
|
return CLLocationCoordinate2D(
|
|
latitude: latitude + (coordinate.latitude - latitude) * clampedRatio,
|
|
longitude: longitude + (coordinate.longitude - longitude) * clampedRatio
|
|
)
|
|
}
|
|
}
|