jsdw_ios/QuickLocation/Section/Schedule/ScheduleDetail/ScheduleDetailView.swift

524 lines
20 KiB
Swift

//
// 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")
}
}