// // 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 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) 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 self?.rootView.emptyLab.isHidden = !empty }) .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) viewModel.start() } 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 ) } }