409 lines
16 KiB
Swift
409 lines
16 KiB
Swift
//
|
||
// 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
|
||
#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()
|
||
|
||
requestGroupInfo()
|
||
}
|
||
|
||
override func viewDidDisappear(_ animated: Bool) {
|
||
super.viewDidDisappear(animated)
|
||
if isMovingFromParent || isBeingDismissed {
|
||
#if !targetEnvironment(simulator)
|
||
rootView.cleanupMap()
|
||
#endif
|
||
}
|
||
}
|
||
|
||
// MARK: - API
|
||
private func requestGroupInfo() {
|
||
GroupService.groupInfo().subscribe { response in
|
||
guard let model = response.model else { return }
|
||
self.groupList = model.groups
|
||
self.popView.setupTagData(model.groups)
|
||
}.disposed(by: disposeBag)
|
||
}
|
||
|
||
// MARK: - Binding
|
||
|
||
private lazy var dataSource: RxTableViewSectionedReloadDataSource<SchedulePointSection> = {
|
||
RxTableViewSectionedReloadDataSource<SchedulePointSection>(
|
||
configureCell: { [weak self] _, tv, indexPath, item in
|
||
let cell: SchedulePointCell = tv.dequeueReusableCell(for: indexPath)
|
||
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
|
||
self.viewModel.updatePointLocation(index: indexPath.row, name: picked.name, address: picked.address)
|
||
// 补充坐标
|
||
var list = self.viewModel.pointsRelay.value
|
||
guard indexPath.row < list.count else { return }
|
||
list[indexPath.row].latitude = picked.coordinate.latitude
|
||
list[indexPath.row].longitude = picked.coordinate.longitude
|
||
self.viewModel.pointsRelay.accept(list)
|
||
}
|
||
self.present(vc, animated: true)
|
||
}
|
||
cell.configure(item: item, index: indexPath.row,
|
||
total: self?.viewModel.pointsRelay.value.count ?? 0,
|
||
onDelete: { [weak self] in
|
||
self?.viewModel.deletePointAt.onNext(indexPath.row)
|
||
})
|
||
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 }
|
||
|
||
// 时间戳(毫秒)
|
||
let ts = Int64(viewModel.selectedDate.value.timeIntervalSince1970 * 1000)
|
||
|
||
// 生成 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
|
||
]
|
||
}
|
||
|
||
let params: [String: Any] = [
|
||
"group_keys": viewModel.selectedGroupKeys,
|
||
"timestamp": ts,
|
||
"points": pointsJSON
|
||
]
|
||
print("📋 Create schedule: \(params)")
|
||
DLToast.show(text: "创建成功")
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
// 缩放至包含所有点
|
||
let lats = validPoints.map { $0.latitude }
|
||
let lons = validPoints.map { $0.longitude }
|
||
if let minLat = lats.min(), let maxLat = lats.max(),
|
||
let minLon = lons.min(), let maxLon = lons.max() {
|
||
let center = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2, longitude: (minLon + maxLon) / 2)
|
||
let span = MACoordinateSpan(latitudeDelta: (maxLat - minLat) * 2.5 + 0.01, longitudeDelta: (maxLon - minLon) * 2.5 + 0.01)
|
||
rootView.mapView.setRegion(MACoordinateRegion(center: center, span: span), 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..<pendingRoutePoints.count - 1 {
|
||
let p = pendingRoutePoints[i]
|
||
if let wp = AMapGeoPoint.location(withLatitude: CGFloat(p.latitude), longitude: CGFloat(p.longitude)) {
|
||
waypoints.append(wp)
|
||
}
|
||
}
|
||
request.waypoints = waypoints
|
||
}
|
||
request.strategy = 0
|
||
// request.city = ""
|
||
routeSearch?.aMapDrivingRouteSearch(request)
|
||
}
|
||
#endif
|
||
|
||
/// 生成数字图标
|
||
private static func numberImage(_ num: Int) -> 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: - 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
|