// // ScheduleDetailView.swift // QuickLocation // // Created by 八条 on 2026/6/25. // import UIKit import RxSwift import RxCocoa import AMapNaviKit final class ScheduleDetailView: UIView { private let disposeBag = DisposeBag() private let panelTopOffset = max(kNaviHeight + 190, UIScreen.main.bounds.height * 0.44) var mapBottomPadding: CGFloat { max(220, bounds.height - detailPanelView.frame.minY + 24) } func configure(_ model: ScheduleModel) { dateLab.text = formattedDate(model.timestamp) editBtn.isHidden = !model.is_own } func cleanupMap() { mapView?.delegate = nil mapView?.removeFromSuperview() mapView = nil } private func setupUI() { backgroundColor = .white addSubview(mapView) addSubview(detailPanelView) addSubview(backBtn) addSubview(editBtn) detailPanelView.addSubview(titleIconView) detailPanelView.addSubview(titleLab) detailPanelView.addSubview(dateLab) detailPanelView.addSubview(tableView) [mapView, detailPanelView, backBtn, editBtn, titleIconView, titleLab, dateLab, tableView] .forEach { $0?.translatesAutoresizingMaskIntoConstraints = false } NSLayoutConstraint.activate([ mapView.topAnchor.constraint(equalTo: topAnchor), mapView.leadingAnchor.constraint(equalTo: leadingAnchor), mapView.trailingAnchor.constraint(equalTo: trailingAnchor), mapView.bottomAnchor.constraint(equalTo: bottomAnchor), backBtn.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 10), backBtn.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 18), backBtn.widthAnchor.constraint(equalToConstant: 44), backBtn.heightAnchor.constraint(equalToConstant: 44), editBtn.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 10), editBtn.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -18), editBtn.widthAnchor.constraint(equalToConstant: 44), editBtn.heightAnchor.constraint(equalToConstant: 44), detailPanelView.topAnchor.constraint(equalTo: topAnchor, constant: panelTopOffset), detailPanelView.leadingAnchor.constraint(equalTo: leadingAnchor), detailPanelView.trailingAnchor.constraint(equalTo: trailingAnchor), detailPanelView.bottomAnchor.constraint(equalTo: bottomAnchor), titleIconView.leadingAnchor.constraint(equalTo: detailPanelView.leadingAnchor, constant: 18), titleIconView.topAnchor.constraint(equalTo: detailPanelView.topAnchor, constant: 23), titleIconView.widthAnchor.constraint(equalToConstant: 18), titleIconView.heightAnchor.constraint(equalToConstant: 18), titleLab.leadingAnchor.constraint(equalTo: titleIconView.trailingAnchor, constant: 8), titleLab.centerYAnchor.constraint(equalTo: titleIconView.centerYAnchor), dateLab.trailingAnchor.constraint(equalTo: detailPanelView.trailingAnchor, constant: -20), dateLab.centerYAnchor.constraint(equalTo: titleLab.centerYAnchor), dateLab.leadingAnchor.constraint(greaterThanOrEqualTo: titleLab.trailingAnchor, constant: 12), tableView.topAnchor.constraint(equalTo: titleIconView.bottomAnchor, constant: 14), tableView.leadingAnchor.constraint(equalTo: detailPanelView.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: detailPanelView.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: detailPanelView.bottomAnchor) ]) } private func setupRx() { backBtn.rx.tap .subscribe(onNext: { AppRouter.shared.popOrDismiss() }) .disposed(by: disposeBag) } private func formattedDate(_ timestamp: Int64) -> String { guard timestamp > 0 else { return "" } let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "yyyy年M月d日" return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000)) } lazy var mapView: MAMapView! = { let view = MAMapView() view.zoomLevel = 14 view.showsUserLocation = false view.showsCompass = false view.userTrackingMode = .none return view }() lazy var detailPanelView: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#FAFAFA") view.layer.cornerRadius = 24 view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] view.clipsToBounds = true return view }() lazy var backBtn: UIButton = { let button = UIButton(type: .custom) button.setImage(UIImage(named: "Common/back"), for: .normal) button.backgroundColor = .clear button.cornerRadius = 12 button.layer.shadowColor = UIColor.black.withAlphaComponent(0.12).cgColor button.layer.shadowOpacity = 1 button.layer.shadowRadius = 8 button.layer.shadowOffset = CGSize(width: 0, height: 3) button.clipsToBounds = false return button }() lazy var editBtn: UIButton = { let button = UIButton(type: .custom) button.setImage(UIImage(named: "IM/setting"), for: .normal) button.backgroundColor = UIColor.white.withAlphaComponent(0.92) button.cornerRadius = 12 button.layer.shadowColor = UIColor.black.withAlphaComponent(0.12).cgColor button.layer.shadowOpacity = 1 button.layer.shadowRadius = 8 button.layer.shadowOffset = CGSize(width: 0, height: 3) button.clipsToBounds = false button.isHidden = true return button }() private lazy var titleIconView: UIImageView = { let view = UIImageView(image: UIImage(named: "Schedule/title_location")) return view }() private lazy var titleLab: UILabel = { let label = UILabel() label.text = "行程详情" label.font = .systemFont(ofSize: 18, weight: .bold) label.textColor = UIColor(hexStr: "#293445") return label }() private lazy var dateLab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = UIColor(hexStr: "#293445") return label }() lazy var tableView: UITableView = { let view = UITableView(frame: .zero, style: .plain) view.backgroundColor = .clear view.separatorStyle = .none view.showsVerticalScrollIndicator = false view.estimatedRowHeight = 150 view.rowHeight = UITableView.automaticDimension view.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: kSafeBottomMargin + 16, right: 0) view.register(ScheduleTimelineCell.self) return view }() override init(frame: CGRect) { super.init(frame: frame) setupUI() setupRx() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } final class ScheduleTimelineCell: UITableViewCell { func configure(_ item: ScheduleTimelineItem) { markerView.role = item.role upperLine.isHidden = item.isFirst lowerLine.isHidden = item.isLast timeLab.text = Self.timeText(item.point.expected_timestamp) locationLab.text = Self.locationText(item.point) let remark = item.point.remark.isEmpty ? "无备注" : item.point.remark remarkLab.text = "备注:\(remark)" configureEvents(item.point.events) if let duration = item.durationToNext { durationBadge.configure( title: (item.role == .waypoint ? "停留 " : "") + Self.durationText(duration) ) durationRow.isHidden = false } else { durationRow.isHidden = true } } private func setupUI() { selectionStyle = .none backgroundColor = .clear contentView.backgroundColor = .clear contentView.addSubview(upperLine) contentView.addSubview(lowerLine) contentView.addSubview(markerView) contentView.addSubview(bodyStack) bodyStack.addArrangedSubview(headerRow) bodyStack.addArrangedSubview(remarkLab) bodyStack.addArrangedSubview(eventScrollView) bodyStack.addArrangedSubview(durationRow) eventScrollView.addSubview(eventStackView) durationRow.addSubview(durationBadge) headerRow.addSubview(timeLab) headerRow.addSubview(locationLab) [upperLine, lowerLine, markerView, bodyStack, headerRow, timeLab, locationLab, eventScrollView, eventStackView, durationRow, durationBadge] .forEach { $0.translatesAutoresizingMaskIntoConstraints = false } let eventHeight = eventScrollView.heightAnchor.constraint(equalToConstant: 42) eventHeight.priority = .defaultHigh let durationHeight = durationRow.heightAnchor.constraint(equalToConstant: 40) durationHeight.priority = .defaultHigh NSLayoutConstraint.activate([ markerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), markerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), markerView.widthAnchor.constraint(equalToConstant: 22), markerView.heightAnchor.constraint(equalToConstant: 22), upperLine.centerXAnchor.constraint(equalTo: markerView.centerXAnchor), upperLine.topAnchor.constraint(equalTo: contentView.topAnchor), upperLine.bottomAnchor.constraint(equalTo: markerView.centerYAnchor), upperLine.widthAnchor.constraint(equalToConstant: 1), lowerLine.centerXAnchor.constraint(equalTo: markerView.centerXAnchor), lowerLine.topAnchor.constraint(equalTo: markerView.centerYAnchor), lowerLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), lowerLine.widthAnchor.constraint(equalToConstant: 1), bodyStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 62), bodyStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), bodyStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 14), bodyStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16), headerRow.heightAnchor.constraint(greaterThanOrEqualToConstant: 26), timeLab.leadingAnchor.constraint(equalTo: headerRow.leadingAnchor), timeLab.centerYAnchor.constraint(equalTo: headerRow.centerYAnchor), timeLab.widthAnchor.constraint(equalToConstant: 64), locationLab.leadingAnchor.constraint(equalTo: timeLab.trailingAnchor, constant: 8), locationLab.trailingAnchor.constraint(equalTo: headerRow.trailingAnchor), locationLab.centerYAnchor.constraint(equalTo: headerRow.centerYAnchor), eventHeight, eventStackView.leadingAnchor.constraint(equalTo: eventScrollView.contentLayoutGuide.leadingAnchor), eventStackView.trailingAnchor.constraint(equalTo: eventScrollView.contentLayoutGuide.trailingAnchor), eventStackView.topAnchor.constraint(equalTo: eventScrollView.contentLayoutGuide.topAnchor), eventStackView.bottomAnchor.constraint(equalTo: eventScrollView.contentLayoutGuide.bottomAnchor), eventStackView.heightAnchor.constraint(equalTo: eventScrollView.frameLayoutGuide.heightAnchor), durationHeight, durationBadge.leadingAnchor.constraint(equalTo: durationRow.leadingAnchor), durationBadge.centerYAnchor.constraint(equalTo: durationRow.centerYAnchor), durationBadge.trailingAnchor.constraint(lessThanOrEqualTo: durationRow.trailingAnchor) ]) } private func configureEvents(_ events: [SchedulePointEventModel]) { eventStackView.arrangedSubviews.forEach { eventStackView.removeArrangedSubview($0) $0.removeFromSuperview() } eventScrollView.isHidden = events.isEmpty for event in events { let chip = ScheduleTimelineEventChip() chip.configure(time: Self.timeText(event.arrival_timestamp), text: event.text) eventStackView.addArrangedSubview(chip) } } private static func locationText(_ point: SchedulePointModel) -> String { if !point.street.isEmpty { return point.street } if !point.formatted_address.isEmpty { return point.formatted_address } return "暂无地点" } private static func timeText(_ timestamp: Int64) -> String { guard timestamp > 0 else { return "--:--" } let formatter = DateFormatter() formatter.dateFormat = "HH:mm" return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000)) } private static func durationText(_ milliseconds: Int64) -> String { let minutes = max(1, milliseconds / 60_000) let hours = minutes / 60 let remainder = minutes % 60 return hours > 0 ? "\(hours)小时\(remainder)分" : "\(minutes)分钟" } private lazy var upperLine = ScheduleTimelineDashLineView() private lazy var lowerLine = ScheduleTimelineDashLineView() private lazy var markerView = ScheduleTimelineMarkerView() private lazy var bodyStack: UIStackView = { let view = UIStackView() view.axis = .vertical view.alignment = .fill view.spacing = 10 return view }() private lazy var headerRow = UIView() private lazy var timeLab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 16, weight: .bold) label.textColor = UIColor(hexStr: "#293445") return label }() private lazy var locationLab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 16, weight: .bold) label.textColor = UIColor(hexStr: "#293445") label.numberOfLines = 1 label.lineBreakMode = .byTruncatingTail return label }() private lazy var remarkLab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 13, weight: .regular) label.textColor = UIColor(hexStr: "#8B8B8B") label.numberOfLines = 0 return label }() private lazy var eventScrollView: UIScrollView = { let view = UIScrollView() view.showsHorizontalScrollIndicator = false view.alwaysBounceHorizontal = false return view }() private lazy var eventStackView: UIStackView = { let view = UIStackView() view.axis = .horizontal view.alignment = .center view.spacing = 8 return view }() private lazy var durationRow = UIView() private lazy var durationBadge = ScheduleTimelineDurationBadge() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } private final class ScheduleTimelineMarkerView: UIView { var role: ScheduleTimelineRole = .waypoint { didSet { setNeedsDisplay() } } override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear isOpaque = false } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func draw(_ rect: CGRect) { guard let context = UIGraphicsGetCurrentContext() else { return } context.clear(rect) switch role { case .waypoint: let dotRect = CGRect(x: rect.midX - 5, y: rect.midY - 5, width: 10, height: 10) context.setFillColor(UIColor(hexStr: "#293445").cgColor) context.fillEllipse(in: dotRect) let innerDotRect = CGRect(x: rect.midX - 2, y: rect.midY - 2, width: 4, height: 4) context.setFillColor(UIColor.white.cgColor) context.fillEllipse(in: innerDotRect) case .start, .end: let color = role == .start ? UIColor(hexStr: "#35C8F4") : UIColor(hexStr: "#FF5665") context.setFillColor(color.cgColor) context.fillEllipse(in: rect.insetBy(dx: 1, dy: 1)) let text = role == .start ? "始" : "终" let attributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 11, weight: .bold), .foregroundColor: UIColor.white ] let size = text.size(withAttributes: attributes) text.draw(at: CGPoint(x: rect.midX - size.width / 2, y: rect.midY - size.height / 2), withAttributes: attributes) } } } private final class ScheduleTimelineDashLineView: UIView { override func layoutSubviews() { super.layoutSubviews() layer.sublayers?.forEach { if $0 is CAShapeLayer { $0.removeFromSuperlayer() } } let line = CAShapeLayer() line.strokeColor = UIColor(hexStr: "#D8D8D8").cgColor line.lineWidth = 1.5 line.lineDashPattern = [5, 5] let path = UIBezierPath() path.move(to: CGPoint(x: bounds.midX, y: 0)) path.addLine(to: CGPoint(x: bounds.midX, y: bounds.maxY)) line.path = path.cgPath layer.addSublayer(line) } } private final class ScheduleTimelineEventChip: UIView { func configure(time: String, text: String) { let status = text.trimmingCharacters(in: .whitespacesAndNewlines) label.text = "\(time) \(status.isEmpty ? "已到达" : status)" } private let label: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 13, weight: .semibold) label.textColor = UIColor(hexStr: "#293445") return label }() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white cornerRadius = 10 layer.shadowColor = UIColor.black.withAlphaComponent(0.06).cgColor layer.shadowOpacity = 1 layer.shadowRadius = 8 layer.shadowOffset = CGSize(width: 0, height: 2) clipsToBounds = false addSubview(label) label.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 13), label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -13), label.centerYAnchor.constraint(equalTo: centerYAnchor), heightAnchor.constraint(equalToConstant: 38) ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } private final class ScheduleTimelineDurationBadge: UIView { func configure(title: String) { titleLab.text = title } private let iconView: UIImageView = { let view = UIImageView(image: UIImage(named: "Schedule/clock")) view.contentMode = .scaleAspectFit return view }() private let titleLab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 13, weight: .semibold) label.textColor = UIColor(hexStr: "#293445") return label }() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white cornerRadius = 10 layer.shadowColor = UIColor.black.withAlphaComponent(0.05).cgColor layer.shadowOpacity = 1 layer.shadowRadius = 8 layer.shadowOffset = CGSize(width: 0, height: 2) clipsToBounds = false addSubview(iconView) addSubview(titleLab) iconView.translatesAutoresizingMaskIntoConstraints = false titleLab.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), iconView.centerYAnchor.constraint(equalTo: centerYAnchor), iconView.widthAnchor.constraint(equalToConstant: 18), iconView.heightAnchor.constraint(equalToConstant: 18), titleLab.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 7), titleLab.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), titleLab.centerYAnchor.constraint(equalTo: centerYAnchor), heightAnchor.constraint(equalToConstant: 38) ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }