// // CreateScheduleVC.swift // QuickLocation // // Created by 八条 on 2026/6/23. // import UIKit import RxSwift import RxCocoa import RxDataSources import SwiftyUserDefaults import BRPickerView import RxGesture import CoreLocation import TagListView import ObjectMapper #if !targetEnvironment(simulator) import AMapNaviKit import AMapSearchKit #endif class CreateScheduleVC: BaseViewController, MAMapViewDelegate { override var isNavigationBarHidden: Bool { true } fileprivate var rootView: CreateScheduleView! private let viewModel: CreateScheduleVM private var popView: CreateSchedulePopView { rootView.createSchedulePopView } override func loadView() { rootView = CreateScheduleView(frame: UIScreen.main.bounds) view = rootView } private var groupList: [GroupInfoModel] = [] override func viewDidLoad() { super.viewDidLoad() popView.tagListView.delegate = self setupMap() bindViewModel() reactiveAction() requestGroupInfo() guard let _ = viewModel.scheduModel else { return } rootView.navView.titleLabel.text = "编辑行程" rootView.deleteBtn.isHidden = false // requestFollowList(id: model.id) } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) if isMovingFromParent || isBeingDismissed { #if !targetEnvironment(simulator) rootView.cleanupMap() #endif } } // MARK: - Actions private func reactiveAction() { rootView.deleteBtn.rx.tap.subscribe(onNext: { _ in guard let model = self.viewModel.scheduModel else { return } self.showConfirmPop(title: "确定要删除吗?", message: "此条行程路线将被永久删除", confirmText: "删除", confirmBlock: { self.requestDelete(id: model.id) }, cancelText: "取消") }).disposed(by: disposeBag) } // MARK: - API private func requestFollowList(id: String) { dl.showLoading() ItineraryService.queryFollowList(id: id).subscribe { response in self.dl.dismiss() }.disposed(by: disposeBag) } private func requestGroupInfo() { GroupService.groupInfo().subscribe { response in guard let model = response.model else { return } self.groupList = model.groups self.popView.setupTagData(model.groups) // 编辑模式:预选中已分享的圈子 #if !targetEnvironment(simulator) if !self.viewModel.selectedGroupKeys.isEmpty { for (i, group) in self.groupList.enumerated() { guard i < self.popView.tagListView.tagViews.count, self.viewModel.selectedGroupKeys.contains(group.group_key) else { continue } self.popView.tagListView.tagViews[i].isSelected = true } } #endif }.disposed(by: disposeBag) } private func requestSet(id: String="", points: [[String: Any]]) { let selectedDate = Int64(viewModel.selectedDate.value.timeIntervalSince1970 * 1000) dl.showLoading() ItineraryService.set(id: id, group_keys: viewModel.selectedGroupKeys, timestamp: selectedDate, points: points).subscribe(onNext: { response in self.dl.dismiss() self.dl.show(text: id.isEmpty ? "创建成功" : "更新成功") { AppRouter.shared.popOrDismiss() } }, onError: { error in guard let code = error.underlyingError?.code else { return } guard let systemConfig = AppContextManager.shared.systemConfig, systemConfig.isIntercept == false else { return } if code == 20010 { // "创建的行程数达到上限" CreateScheduleVipPopView.show() } else { self.dl.show(text: error.localizedDescription) } }).disposed(by: disposeBag) } private func requestDelete(id: String) { dl.showLoading() ItineraryService.delete(id: id).subscribe { response in self.dl.dismiss() self.dl.show(text: "删除成功") { AppRouter.shared.popOrDismiss() } }.disposed(by: disposeBag) } // MARK: - Binding private lazy var dataSource: RxTableViewSectionedReloadDataSource = { RxTableViewSectionedReloadDataSource( configureCell: { [weak self] _, tv, indexPath, row in switch row { case .add: let cell: ScheduleAddPointCell = tv.dequeueReusableCell(for: indexPath) cell.onAdd = { [weak self] in self?.viewModel.addPointTapped.onNext(()) } return cell case .point(let pointIndex, let item, let role): let cell: SchedulePointCell = tv.dequeueReusableCell(for: indexPath) cell.configure(item: item, role: role) cell.onDelete = { [weak self] in self?.viewModel.deletePointAt.onNext(pointIndex) } cell.onDragGesture = { [weak self] gesture in self?.handleMiddlePointDrag(gesture, pointIndex: pointIndex, cell: cell) } cell.onTimeTap = { [weak self] in self?.showTimePicker(for: pointIndex) } cell.onLocationTap = { [weak self] in self?.showLocationPicker(for: pointIndex, item: item) } cell.remarkTextField?.rx.controlEvent(.editingDidEnd) .subscribe(onNext: { [weak self, weak cell] in guard let self = self, let text = cell?.remarkTextField?.text else { return } var list = self.viewModel.pointsRelay.value guard pointIndex < list.count else { return } list[pointIndex].remark = text self.viewModel.pointsRelay.accept(list) }) .disposed(by: cell.disposeBag) return cell } }) }() fileprivate func bindViewModel() { viewModel.dateString .map { $0 } .bind(to: popView.dateLab.rx.text) .disposed(by: disposeBag) viewModel.listItems .map { [SchedulePointSection(model: "", items: $0)] } .bind(to: popView.tableView.rx.items(dataSource: dataSource)) .disposed(by: disposeBag) popView.tableView.rx.setDelegate(self) .disposed(by: disposeBag) // 动态高度 + 刷新地图路线 viewModel.pointsRelay .observe(on: MainScheduler.asyncInstance) .subscribe(onNext: { [weak self] items in guard let self = self else { return } let h = CGFloat(items.count) * ScheduleTimelineMetric.pointRowHeight + ScheduleTimelineMetric.addRowHeight self.popView.tableView.layoutChain.height(h) self.refreshMapPoints() }) .disposed(by: disposeBag) popView.dateLab.rx.tapGesture .when(.recognized) .subscribe(onNext: { [weak self] _ in self?.showDatePicker() }) .disposed(by: disposeBag) popView.createBtn.rx.tap .subscribe(onNext: { [weak self] _ in self?.handleCreate() }) .disposed(by: disposeBag) } private func showTimePicker(for pointIndex: Int) { let picker = BRDatePickerView(pickerMode: .HM) picker.title = "选择到达时间" if pointIndex < viewModel.pointsRelay.value.count, let existing = viewModel.pointsRelay.value[pointIndex].expectedTime { picker.selectDate = existing } picker.pickerStyle = makeSchedulePickerStyle() picker.pickerFooterView = makeSchedulePickerFooter { [weak picker] in picker?.doneBlock?() picker?.dismiss() } picker.resultBlock = { [weak self] date, _ in guard let self = self, let d = date else { return } var list = self.viewModel.pointsRelay.value guard pointIndex < list.count else { return } let cal = Calendar.current var comps = cal.dateComponents([.year, .month, .day], from: self.viewModel.selectedDate.value) let timeComps = cal.dateComponents([.hour, .minute], from: d) comps.hour = timeComps.hour comps.minute = timeComps.minute if let merged = cal.date(from: comps) { list[pointIndex].expectedTime = merged self.viewModel.pointsRelay.accept(list) } } picker.show() } private func showLocationPicker(for pointIndex: Int, item: SchedulePointItem) { let coord = item.latitude != 0 || item.longitude != 0 ? CLLocationCoordinate2D(latitude: item.latitude, longitude: item.longitude) : kCLLocationCoordinate2DInvalid let vc = LocationPickerVC() vc.modalPresentationStyle = .fullScreen if !item.locationName.isEmpty { vc.initialLocation = PickedLocation( name: item.locationName, address: item.address, coordinate: coord, province: item.province, city: item.city, district: item.district, street: item.street, country: item.country, formatted_address: item.formatted_address ) } vc.onPickedLocation = { [weak self] picked in guard let self = self else { return } var list = self.viewModel.pointsRelay.value guard pointIndex < list.count else { return } list[pointIndex].locationName = picked.name list[pointIndex].address = picked.address list[pointIndex].latitude = picked.coordinate.latitude list[pointIndex].longitude = picked.coordinate.longitude list[pointIndex].province = picked.province list[pointIndex].city = picked.city list[pointIndex].district = picked.district list[pointIndex].street = picked.street list[pointIndex].country = picked.country list[pointIndex].formatted_address = picked.formatted_address self.viewModel.pointsRelay.accept(list) } present(vc, animated: true) } // MARK: - Middle point drag reorder private var dragSnapshot: UIView? private var dragSourcePointIndex: Int? private var dragTargetPointIndex: Int? private weak var dragSourceCell: SchedulePointCell? private func handleMiddlePointDrag(_ gesture: UILongPressGestureRecognizer, pointIndex: Int, cell: SchedulePointCell) { let points = viewModel.pointsRelay.value guard points.count > 2, pointIndex > 0, pointIndex < points.count - 1 else { return } let table = popView.tableView let locationInTable = gesture.location(in: table) switch gesture.state { case .began: guard let snapshot = cell.snapshotView(afterScreenUpdates: true) else { return } let frameInPop = table.convert(cell.frame, to: popView) snapshot.frame = frameInPop snapshot.alpha = 0.92 snapshot.layer.shadowColor = UIColor.black.cgColor snapshot.layer.shadowOpacity = 0.2 snapshot.layer.shadowRadius = 6 snapshot.layer.shadowOffset = CGSize(width: 0, height: 2) popView.addSubview(snapshot) dragSnapshot = snapshot dragSourcePointIndex = pointIndex dragTargetPointIndex = pointIndex dragSourceCell = cell popView.isReorderingPoints = true cell.alpha = 0.15 UIView.animate(withDuration: 0.12) { snapshot.transform = CGAffineTransform(scaleX: 1.02, y: 1.02) } case .changed: guard let snapshot = dragSnapshot else { return } let locationInPop = gesture.location(in: popView) snapshot.center = CGPoint(x: snapshot.center.x, y: locationInPop.y) // 拖动过程只记录目标,松手再改数据,避免 reload 打断手势 if let targetPath = table.indexPathForRow(at: locationInTable) { let items = CreateScheduleVM.buildListItems(from: viewModel.pointsRelay.value) if targetPath.row < items.count, case .point(let targetPointIndex, _, .middle) = items[targetPath.row] { dragTargetPointIndex = targetPointIndex } } // 内容较多时,靠近边缘自动滚一下外层 scroll autoScrollPopIfNeeded(locationInPop: locationInPop) case .ended, .cancelled, .failed: if gesture.state == .ended, let from = dragSourcePointIndex, let to = dragTargetPointIndex, from != to { viewModel.movePoint.onNext((from: from, to: to)) } finishMiddlePointDrag() default: break } } private func autoScrollPopIfNeeded(locationInPop: CGPoint) { let scroll = popView.scrollView let edge: CGFloat = 60 let maxY = popView.bounds.height - edge var offset = scroll.contentOffset if locationInPop.y < edge { offset.y = max(0, offset.y - 8) scroll.setContentOffset(offset, animated: false) } else if locationInPop.y > maxY { let maxOffset = max(0, scroll.contentSize.height - scroll.bounds.height) offset.y = min(maxOffset, offset.y + 8) scroll.setContentOffset(offset, animated: false) } } private func finishMiddlePointDrag() { let snapshot = dragSnapshot let sourceCell = dragSourceCell dragSnapshot = nil dragSourcePointIndex = nil dragTargetPointIndex = nil dragSourceCell = nil popView.isReorderingPoints = false UIView.animate(withDuration: 0.18, animations: { snapshot?.alpha = 0 snapshot?.transform = .identity }, completion: { _ in snapshot?.removeFromSuperview() }) sourceCell?.alpha = 1 popView.tableView.visibleCells.forEach { $0.alpha = 1 } } private func handleCreate() { let points = viewModel.pointsRelay.value let hasLocation = points.filter { $0.latitude != 0 || $0.longitude != 0 } let hasTime = points.filter { $0.expectedTime != nil } // 校验 guard points.count >= 2 else { DLToast.show(text: "至少需要两个行程点"); return } guard hasLocation.count == points.count else { DLToast.show(text: "请为每个行程点选择地点"); return } guard hasTime.count == points.count else { DLToast.show(text: "请为每个行程点选择到达时间"); return } guard !viewModel.selectedGroupKeys.isEmpty else { DLToast.show(text: "请选择分享的圈子"); return } // 生成 points 数组 let pointsJSON: [[String: Any]] = points.map { p in let expectedTs = p.expectedTime.map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0 return [ "point": ["lat": p.latitude, "lng": p.longitude], "address": [ "formatted_address": p.formatted_address, "country": p.country, "province": p.province, "city": p.city, "district": p.district, "street": p.street ], "expected_timestamp": expectedTs, "remark": p.remark ] } requestSet(id: viewModel.scheduModel?.id ?? "", points: pointsJSON) } private func setupMap() { #if !targetEnvironment(simulator) rootView.mapView.delegate = self rootView.mapView.showsUserLocation = false if let lat = Defaults[\.currentLatitude], let lon = Defaults[\.currentLongitude] { let coord = CLLocationCoordinate2D(latitude: lat, longitude: lon) if CLLocationCoordinate2DIsValid(coord) { rootView.mapView.setCenter(coord, animated: false) rootView.mapView.setZoomLevel(18, animated: false) } } #endif } // MARK: - Date / Time Picker private func showDatePicker() { let picker = BRDatePickerView(pickerMode: .YMD) picker.title = "选择日期" picker.minDate = Date() picker.maxDate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) picker.selectDate = viewModel.selectedDate.value picker.pickerStyle = makeSchedulePickerStyle() picker.pickerFooterView = makeSchedulePickerFooter { [weak picker] in picker?.doneBlock?() picker?.dismiss() } picker.resultBlock = { [weak self] date, _ in if let d = date { self?.viewModel.selectedDate.accept(d) } } picker.show() } private func makeSchedulePickerStyle() -> BRPickerStyle { let style = BRPickerStyle() let width = UIScreen.main.bounds.width style.topCornerRadius = 20 style.hiddenShadowLine = true style.hiddenTitleLine = true style.hiddenDoneBtn = true style.titleBarHeight = 56 style.titleBarColor = .white style.alertViewColor = .white style.maskColor = UIColor.black.withAlphaComponent(0.4) style.titleTextColor = UIColor(hexStr: "#293445") style.titleTextFont = .systemFont(ofSize: 18, weight: .semibold) style.titleLabelFrame = CGRect(x: 0, y: 0, width: 150, height: 56) style.cancelBtnImage = UIImage(named: "Common/x_black") style.cancelBtnTitle = "" style.cancelColor = UIColor(hexStr: "#F2F2F2") style.cancelCornerRadius = 14 style.cancelBorderStyle = .fill style.cancelBtnFrame = CGRect(x: width - 16 - 28, y: 14, width: 28, height: 28) style.pickerColor = .white style.pickerHeight = 230 style.rowHeight = 44 style.clearPickerNewStyle = true style.selectRowColor = UIColor(hexStr: "#E3F6FF") style.selectRowTextColor = UIColor(hexStr: "#16B3FF") style.selectRowTextFont = .systemFont(ofSize: 18, weight: .medium) style.pickerTextColor = UIColor(hexStr: "#333333") style.pickerTextFont = .systemFont(ofSize: 16, weight: .regular) return style } private func makeSchedulePickerFooter(onDone: @escaping () -> Void) -> UIView { let width = UIScreen.main.bounds.width let footer = UIView(frame: CGRect(x: 0, y: 0, width: width, height: 78)) footer.backgroundColor = .white let btn = UIButton(type: .custom) btn.setTitle("确定", for: .normal) btn.setTitleColor(.white, for: .normal) btn.titleLabel?.font = FontManager.boboBold(18) btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) btn.layer.cornerRadius = 20 btn.clipsToBounds = true btn.frame = CGRect(x: 30, y: 8, width: width - 60, height: 56) btn.addAction(UIAction { _ in onDone() }, for: .touchUpInside) footer.addSubview(btn) return footer } // MARK: - 路线规划 #if !targetEnvironment(simulator) private let routeSearch = AMapSearchAPI() #endif private var routeOverlays: [MAPolyline] = [] private var pointAnnotations: [MAPointAnnotation] = [] /// 待规划的有效点队列(refreshMapPoints 先存点,异步回调解锁) private var pendingRoutePoints: [CLLocationCoordinate2D] = [] private func refreshMapPoints() { let points = viewModel.pointsRelay.value #if !targetEnvironment(simulator) routeSearch?.delegate = self // 清除旧标注和路线 for ann in pointAnnotations { rootView.mapView.removeAnnotation(ann) } for ol in routeOverlays { rootView.mapView.remove(ol) } pointAnnotations.removeAll() routeOverlays.removeAll() // 添加带序号的标注 let validPoints = points.filter { $0.latitude != 0 || $0.longitude != 0 } for (i, p) in validPoints.enumerated() { let ann = MAPointAnnotation() ann.coordinate = CLLocationCoordinate2D(latitude: p.latitude, longitude: p.longitude) ann.title = "\(i + 1)" rootView.mapView.addAnnotation(ann) pointAnnotations.append(ann) } // 请求驾车路线 if validPoints.count >= 2 { pendingRoutePoints = validPoints.map { CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) } requestRoute() } // 缩放至包含所有点 if !pointAnnotations.isEmpty { rootView.mapView.showAnnotations(pointAnnotations, edgePadding: UIEdgeInsets(top: kNaviHeight + 20, left: 30, bottom: kScreenHeight / 3 + 20, right: 30), animated: true) } #endif } #if !targetEnvironment(simulator) private func requestRoute() { guard pendingRoutePoints.count >= 2 else { return } let request = AMapDrivingRouteSearchRequest() request.origin = AMapGeoPoint.location(withLatitude: CGFloat(pendingRoutePoints[0].latitude), longitude: CGFloat(pendingRoutePoints[0].longitude)) request.destination = AMapGeoPoint.location(withLatitude: CGFloat(pendingRoutePoints.last!.latitude), longitude: CGFloat(pendingRoutePoints.last!.longitude)) // 中间途经点 if pendingRoutePoints.count > 2 { var waypoints: [AMapGeoPoint] = [] for i in 1.. UIImage? { let size = CGSize(width: 20, height: 20) let rect = CGRect(origin: .zero, size: size) UIGraphicsBeginImageContextWithOptions(size, false, 0) guard let ctx = UIGraphicsGetCurrentContext() else { return nil } // 白色边框 ctx.setLineWidth(1) ctx.setStrokeColor(UIColor.white.cgColor) // 蓝色填充 ctx.setFillColor(UIColor(hexStr: "#16B3FF").cgColor) let path = UIBezierPath(ovalIn: rect) path.fill() path.stroke() // 白色文字 let text = "\(num)" as NSString let attrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 11), .foregroundColor: UIColor.white] let strSize = text.size(withAttributes: attrs) text.draw(at: CGPoint(x: (size.width - strSize.width) / 2, y: (size.height - strSize.height) / 2)) let img = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return img } // MARK: - Map func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! { guard !(annotation is MAUserLocation), let pointAnn = annotation as? MAPointAnnotation else { return nil } if let num = Int(pointAnn.title ?? "") { let id = "PointPin" var view = mapView.dequeueReusableAnnotationView(withIdentifier: id) if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) } else { view?.annotation = annotation } view?.image = Self.numberImage(num) view?.centerOffset = CGPoint(x: 0, y: -15) return view } return nil } func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! { if let polyline = overlay as? MAPolyline { let r = MAPolylineRenderer(polyline: polyline) r?.strokeColor = UIColor(hexStr: "#16B3FF") r?.lineWidth = 3 r?.lineDashType = kMALineDashTypeSquare return r } return nil } // MARK: - Init init(routeId: String, scheduleJson: [String: Any]) { let model = ScheduleModel.init(JSON: scheduleJson) self.viewModel = CreateScheduleVM(scheduleJson.isEmpty ? nil : model) super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } // MARK: - UITableViewDelegate (row height) extension CreateScheduleVC: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { let items = CreateScheduleVM.buildListItems(from: viewModel.pointsRelay.value) guard indexPath.row < items.count else { return ScheduleTimelineMetric.pointRowHeight } switch items[indexPath.row] { case .add: return ScheduleTimelineMetric.addRowHeight case .point: return ScheduleTimelineMetric.pointRowHeight } } } // MARK: - TagListViewDelegate extension CreateScheduleVC: TagListViewDelegate { func tagPressed(_ title: String, tagView: TagView, sender: TagListView) { tagView.isSelected = !tagView.isSelected // 根据 tagView 在 tagViews 中的索引获取对应的 group_key guard let idx = sender.tagViews.firstIndex(of: tagView), idx < groupList.count else { return } let key = groupList[idx].group_key viewModel.toggleGroupKey(key) print("📋 selectedGroupKeys: \(viewModel.selectedGroupKeys)") } } #if !targetEnvironment(simulator) // MARK: - AMapSearchDelegate extension CreateScheduleVC: AMapSearchDelegate { func onRouteSearchDone(_ request: AMapRouteSearchBaseRequest!, response: AMapRouteSearchResponse!) { guard let path = response.route?.paths?.first as? AMapPath else { return } var coords: [CLLocationCoordinate2D] = [] for step in path.steps { guard let polylineStr = step.polyline else { continue } for point in polylineStr.components(separatedBy: ";") { let latLon = point.components(separatedBy: ",") if latLon.count == 2, let lon = Double(latLon[0]), let lat = Double(latLon[1]) { coords.append(CLLocationCoordinate2D(latitude: lat, longitude: lon)) } } } guard coords.count > 1 else { return } var mutableCoords = coords if let polyline = MAPolyline(coordinates: &mutableCoords, count: UInt(coords.count)) { rootView.mapView.add(polyline) routeOverlays.append(polyline) } } func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) { print("Route error: \(error.localizedDescription)") } } #endif