// // CreateScheduleVC.swift // QuickLocation // // Created by 八条 on 2026/6/23. // import UIKit import RxSwift import RxCocoa import RxDataSources import SwiftyUserDefaults import BRPickerView import RxGesture #if !targetEnvironment(simulator) import AMapNaviKit import AMapSearchKit import TagListView import ObjectMapper #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.navTitleLabel.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 } 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, item in let cell: SchedulePointCell = tv.dequeueReusableCell(for: indexPath) cell.configure(item: item, index: indexPath.row, total: self?.viewModel.pointsRelay.value.count ?? 0, onDelete: { [weak self] in self?.viewModel.deletePointAt.onNext(indexPath.row) }) // 到达时间 cell.onTimeTap = { [weak self] in guard let self = self else { return } let picker = BRDatePickerView(pickerMode: .HM) picker.title = "选择到达时间" let style = BRPickerStyle() style.selectRowTextColor = UIColor(hexStr: "#16B3FF") picker.pickerStyle = style picker.resultBlock = { date, _ in guard let d = date else { return } var list = self.viewModel.pointsRelay.value guard indexPath.row < list.count else { return } // 用今天的日期 + 选择的时间拼成完整时间戳 let cal = Calendar.current let today = Date() var comps = cal.dateComponents([.year, .month, .day], from: today) let timeComps = cal.dateComponents([.hour, .minute], from: d) comps.hour = timeComps.hour comps.minute = timeComps.minute if let merged = cal.date(from: comps) { list[indexPath.row].expectedTime = merged self.viewModel.pointsRelay.accept(list) } } picker.show() } // 地点 cell.onLocationTap = { [weak self] in guard let self = self else { return } 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 = { picked in var list = self.viewModel.pointsRelay.value guard indexPath.row < list.count else { return } list[indexPath.row].locationName = picked.name list[indexPath.row].address = picked.address list[indexPath.row].latitude = picked.coordinate.latitude list[indexPath.row].longitude = picked.coordinate.longitude list[indexPath.row].province = picked.province list[indexPath.row].city = picked.city list[indexPath.row].district = picked.district list[indexPath.row].street = picked.street list[indexPath.row].country = picked.country list[indexPath.row].formatted_address = picked.formatted_address self.viewModel.pointsRelay.accept(list) } self.present(vc, animated: true) } // 备注 cell.remarkTF.rx.controlEvent(.editingDidEnd) .subscribe(onNext: { guard let self = self, let text = cell.remarkTF.text else { return } var list = self.viewModel.pointsRelay.value guard indexPath.row < list.count else { return } list[indexPath.row].remark = text self.viewModel.pointsRelay.accept(list) }) .disposed(by: cell.disposeBag) return cell }) }() fileprivate func bindViewModel() { // 日期 viewModel.dateString .bind(to: popView.dateLab.rx.text) .disposed(by: disposeBag) // tableView viewModel.pointsRelay .map { [SchedulePointSection(model: "", items: $0)] } .bind(to: popView.tableView.rx.items(dataSource: dataSource)) .disposed(by: disposeBag) // 动态高度 + 刷新地图路线 viewModel.pointsRelay .observe(on: MainScheduler.asyncInstance) .subscribe(onNext: { [weak self] items in guard let self = self else { return } let h = max(CGFloat(items.count) * 122, 122) self.popView.tableView.layoutChain.height(h) self.refreshMapPoints() }) .disposed(by: disposeBag) // 添加 popView.addBtn.rx.tap .bind(to: viewModel.addPointTapped) .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 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 Picker private func showDatePicker() { let picker = BRDatePickerView(pickerMode: .YMD) picker.minDate = Date() picker.maxDate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) let style = BRPickerStyle() style.selectRowTextColor = UIColor(hexStr: "#16B3FF") picker.pickerStyle = style picker.resultBlock = { [weak self] date, _ in if let d = date { self?.viewModel.selectedDate.accept(d) } } picker.show() } // 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, 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: - 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