// // GroupItineraryView.swift // QuickLocation // import UIKit import RxSwift import RxCocoa import SwiftDate /// 圈子 Tab 下的「行程」内容页 final class GroupItineraryView: UIView { var disposeBag = DisposeBag() var onSwitchGroup: (() -> Void)? var onDeleteSchedule: ((ScheduleModel) -> Void)? var onSelectSchedule: ((ScheduleModel) -> Void)? func updateGroupName(_ name: String) { groupNameLab.text = name.isEmpty ? " " : name } func reloadSchedules(_ list: [ScheduleModel]) { scheduleSections = Self.groupByDay(list) tableView.reloadData() tableView.refresh(status: .noMoreData, isEmpty: list.isEmpty) } private var scheduleSections: [(dayText: String, dateText: String, items: [ScheduleModel])] = [] private func setupUI() { addSubview(headerRow) headerRow.addSubview(groupIcon) headerRow.addSubview(groupNameLab) headerRow.addSubview(changeGroupBtn) addSubview(tableView) addSubview(createBtn) headerRow.layoutChain .top(8) .edgesHorzontal(16) .height(28) groupIcon.layoutChain .left() .centerY() .width(22) .height(22) changeGroupBtn.layoutChain .right() .centerY() .height(28) groupNameLab.layoutChain .leftToRightOfView(groupIcon, offset: 8) .centerY() .rightToLeftOfView(changeGroupBtn, offset: -8, relation: .lessThanOrEqual) tableView.layoutChain .topToBottomOfView(headerRow, offset: 12) .edgesHorzontal() .bottom() createBtn.layoutChain .bottom(kSafeBottomMargin + 102) .right(20) .width(54) .heightToWidth(1) } private func setupRx() { createBtn.rx.tap.subscribe(onNext: { _ in AppRouter.push(Route.createSchedule) }).disposed(by: disposeBag) changeGroupBtn.rx.tap .subscribe(onNext: { [weak self] in self?.onSwitchGroup?() }) .disposed(by: disposeBag) } private static func groupByDay(_ list: [ScheduleModel]) -> [(dayText: String, dateText: String, items: [ScheduleModel])] { let sorted = list.sorted { $0.timestamp > $1.timestamp } var map: [String: [ScheduleModel]] = [:] var order: [String] = [] var dayDateMap: [String: (dayText: String, dateText: String)] = [:] let fmt = DateFormatter() fmt.locale = Locale(identifier: "zh_CN") let calendar = Calendar.current for item in sorted { let date = Date(timeIntervalSince1970: TimeInterval(item.timestamp) / 1000) fmt.dateFormat = "M月d日" let dateText = fmt.string(from: date) let dayText: String if calendar.isDateInToday(date) { dayText = "今天" } else if calendar.isDateInYesterday(date) { dayText = "昨天" } else { fmt.dateFormat = "EEEE" dayText = fmt.string(from: date) } let key = "\(dayText) \(dateText)" if map[key] == nil { order.append(key) dayDateMap[key] = (dayText, dateText) map[key] = [] } map[key]?.append(item) } return order.map { key in let pair = dayDateMap[key] ?? ("", "") return (pair.dayText, pair.dateText, map[key] ?? []) } } lazy var headerRow: UIView = { let v = UIView() v.backgroundColor = .clear return v }() lazy var groupIcon: UIImageView = { let iv = UIImageView(image: UIImage(named: "Home/group_name_icon")) iv.contentMode = .scaleAspectFit return iv }() lazy var groupNameLab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 16, weight: .bold) label.textColor = UIColor(hexStr: "#353B4F") label.text = " " return label }() lazy var changeGroupBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("切换圈子 ", for: .normal) btn.setTitleColor(UIColor(hexStr: "#353B4F"), for: .normal) btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) btn.setImage(UIImage(named: "Common/arrow_right"), for: .normal) btn.semanticContentAttribute = .forceRightToLeft return btn }() lazy var tableView: UITableView = { let tv = UITableView(frame: .zero, style: .grouped) tv.backgroundColor = UIColor(hexStr: "#FAFAFA") tv.separatorStyle = .none tv.showsVerticalScrollIndicator = false tv.register(GroupItineraryCell.self) tv.dataSource = self tv.delegate = self tv.estimatedRowHeight = 100 tv.rowHeight = UITableView.automaticDimension tv.sectionHeaderHeight = 36 tv.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 97 + kSafeBottomMargin, right: 0) if #available(iOS 15.0, *) { tv.sectionHeaderTopPadding = 0 } tv.dl.addEmptyDataSet { emptyDataSet in emptyDataSet.emptyNoDataTitle = "这里怎么什么都没有~" emptyDataSet.emptyNoDataDetail = "" emptyDataSet.customButtonTitle = "" emptyDataSet.backgroundColor = .clear emptyDataSet.offsetY = -100 } return tv }() lazy var createBtn: UIButton = { let btn = UIButton() btn.setImage(UIImage(named: "Schedule/create"), for: .normal) return btn }() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear setupUI() setupRx() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } // MARK: - UITableView extension GroupItineraryView: UITableViewDataSource, UITableViewDelegate { func numberOfSections(in tableView: UITableView) -> Int { scheduleSections.count } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { scheduleSections[section].items.count } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let header = UIView() header.backgroundColor = .clear let sectionData = scheduleSections[section] let label = UILabel() let color = UIColor(hexStr: "#353B4F") let attr = NSMutableAttributedString() attr.append(NSAttributedString( string: sectionData.dayText, attributes: [ .font: UIFont.systemFont(ofSize: 16, weight: .bold), .foregroundColor: color ] )) if !sectionData.dateText.isEmpty { attr.append(NSAttributedString( string: " \(sectionData.dateText)", attributes: [ .font: UIFont.systemFont(ofSize: 13, weight: .medium), .foregroundColor: color ] )) } label.attributedText = attr header.addSubview(label) label.layoutChain.left(16).centerY() return header } func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { .leastNormalMagnitude } func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { UIView() } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell: GroupItineraryCell = tableView.dequeueReusableCell(for: indexPath) cell.configure(scheduleSections[indexPath.section].items[indexPath.row]) return cell } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { onSelectSchedule?(scheduleSections[indexPath.section].items[indexPath.row]) } } // MARK: - GroupItineraryCell final class GroupItineraryCell: UITableViewCell { func configure(_ model: ScheduleModel) { avatarImg.image = model.userIcon nameLab.text = model.nick_name.isEmpty ? "未命名用户" : model.nick_name followIcon.isHidden = !model.is_follow let points = Self.orderedPoints(model.points) startLocationLab.text = Self.locationName(points.first) endLocationLab.text = Self.locationName(points.last, maximumCharacters: 4) durationLab.text = Self.durationText(from: points.first, to: points.last) configureTags(model.groups.map(\.group_name).filter { !$0.isEmpty }) } override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.backgroundColor = .clear contentView.addSubview(cardView) cardView.addSubview(avatarImg) cardView.addSubview(nameLab) cardView.addSubview(followIcon) cardView.addSubview(startMarkerLab) cardView.addSubview(startLocationLab) cardView.addSubview(routeLineView) cardView.addSubview(durationLab) cardView.addSubview(endMarkerLab) cardView.addSubview(endLocationLab) cardView.addSubview(tagScrollView) tagScrollView.addSubview(tagStackView) cardView.layoutChain .edgesVertical(6) .edgesHorzontal(15) avatarImg.layoutChain .top(12) .left(14) .width(28) .height(28) nameLab.layoutChain .leftToRightOfView(avatarImg, offset: 8) .centerY(avatarImg) followIcon.layoutChain .leftToRightOfView(nameLab, offset: 7) .centerY(nameLab) .width(20) .height(20) followIcon.translatesAutoresizingMaskIntoConstraints = false followIcon.trailingAnchor.constraint(lessThanOrEqualTo: cardView.trailingAnchor, constant: -14).isActive = true startMarkerLab.translatesAutoresizingMaskIntoConstraints = false startLocationLab.translatesAutoresizingMaskIntoConstraints = false routeLineView.translatesAutoresizingMaskIntoConstraints = false durationLab.translatesAutoresizingMaskIntoConstraints = false endMarkerLab.translatesAutoresizingMaskIntoConstraints = false endLocationLab.translatesAutoresizingMaskIntoConstraints = false tagScrollView.translatesAutoresizingMaskIntoConstraints = false tagStackView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ startMarkerLab.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 14), startMarkerLab.topAnchor.constraint(equalTo: avatarImg.bottomAnchor, constant: 10), startMarkerLab.widthAnchor.constraint(equalToConstant: 20), startMarkerLab.heightAnchor.constraint(equalToConstant: 20), startLocationLab.leadingAnchor.constraint(equalTo: startMarkerLab.trailingAnchor, constant: 7), startLocationLab.centerYAnchor.constraint(equalTo: startMarkerLab.centerYAnchor), startLocationLab.widthAnchor.constraint(lessThanOrEqualToConstant: 82), endLocationLab.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -14), endLocationLab.centerYAnchor.constraint(equalTo: startMarkerLab.centerYAnchor), endLocationLab.widthAnchor.constraint(lessThanOrEqualToConstant: 82), endMarkerLab.trailingAnchor.constraint(equalTo: endLocationLab.leadingAnchor, constant: -7), endMarkerLab.centerYAnchor.constraint(equalTo: startMarkerLab.centerYAnchor), endMarkerLab.widthAnchor.constraint(equalToConstant: 20), endMarkerLab.heightAnchor.constraint(equalToConstant: 20), routeLineView.leadingAnchor.constraint(equalTo: startLocationLab.trailingAnchor, constant: 8), routeLineView.trailingAnchor.constraint(equalTo: endMarkerLab.leadingAnchor, constant: -8), routeLineView.centerYAnchor.constraint(equalTo: startMarkerLab.centerYAnchor, constant: 4), routeLineView.heightAnchor.constraint(equalToConstant: 1), routeLineView.widthAnchor.constraint(greaterThanOrEqualToConstant: 28), durationLab.centerXAnchor.constraint(equalTo: routeLineView.centerXAnchor), durationLab.bottomAnchor.constraint(equalTo: routeLineView.topAnchor, constant: -3), durationLab.widthAnchor.constraint(lessThanOrEqualTo: routeLineView.widthAnchor, constant: 24), tagScrollView.topAnchor.constraint(equalTo: startMarkerLab.bottomAnchor, constant: 8), tagScrollView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 90), tagScrollView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -14), tagScrollView.heightAnchor.constraint(equalToConstant: 18), tagScrollView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -10), tagStackView.leadingAnchor.constraint(equalTo: tagScrollView.contentLayoutGuide.leadingAnchor), tagStackView.trailingAnchor.constraint(equalTo: tagScrollView.contentLayoutGuide.trailingAnchor), tagStackView.topAnchor.constraint(equalTo: tagScrollView.contentLayoutGuide.topAnchor), tagStackView.bottomAnchor.constraint(equalTo: tagScrollView.contentLayoutGuide.bottomAnchor), tagStackView.heightAnchor.constraint(equalTo: tagScrollView.frameLayoutGuide.heightAnchor), tagStackView.widthAnchor.constraint(greaterThanOrEqualTo: tagScrollView.frameLayoutGuide.widthAnchor) ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private lazy var cardView: UIView = { let v = UIView() v.backgroundColor = .white v.cornerRadius = 16 return v }() private lazy var avatarImg: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 10 iv.clipsToBounds = true return iv }() private lazy var nameLab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 15, weight: .bold) label.textColor = UIColor(hexStr: "#353B4F") label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return label }() private lazy var followIcon: UIImageView = { let view = UIImageView(image: Self.followBadgeImage()) view.contentMode = .scaleAspectFit return view }() private static func followBadgeImage() -> UIImage? { let size = CGSize(width: 22, height: 18) UIGraphicsBeginImageContextWithOptions(size, false, 0) defer { UIGraphicsEndImageContext() } let configuration = UIImage.SymbolConfiguration(pointSize: 13, weight: .bold) UIImage(systemName: "heart.fill", withConfiguration: configuration)? .withTintColor(UIColor(hexStr: "#FF9AC1"), renderingMode: .alwaysOriginal) .draw(in: CGRect(x: 0, y: 1, width: 15, height: 15)) UIImage(systemName: "heart.fill", withConfiguration: configuration)? .withTintColor(UIColor(hexStr: "#FF78AC"), renderingMode: .alwaysOriginal) .draw(in: CGRect(x: 8, y: 3, width: 14, height: 14)) return UIGraphicsGetImageFromCurrentImageContext() } private lazy var startMarkerLab: UILabel = makeMarkerLabel(text: "始") private lazy var endMarkerLab: UILabel = makeMarkerLabel(text: "终") private func makeMarkerLabel(text: String) -> UILabel { let label = UILabel() label.text = text label.font = FontManager.youSheBiaoTiHei(11) label.textColor = UIColor(hexStr: "#58EDFF") label.textAlignment = .center label.backgroundColor = UIColor(hexStr: "#293445") label.cornerRadius = 7 label.clipsToBounds = true return label } private lazy var startLocationLab: UILabel = makeLocationLabel() private lazy var endLocationLab: UILabel = makeLocationLabel() private func makeLocationLabel() -> UILabel { let label = UILabel() label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = UIColor(hexStr: "#353B4F") label.lineBreakMode = .byTruncatingTail label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return label } private lazy var durationLab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12, weight: .regular) label.textColor = UIColor(hexStr: "#888888") label.textAlignment = .center label.backgroundColor = .white label.lineBreakMode = .byTruncatingTail return label }() private lazy var routeLineView: GroupItineraryDashLineView = { GroupItineraryDashLineView() }() private lazy var tagScrollView: UIScrollView = { let view = UIScrollView() view.showsHorizontalScrollIndicator = false view.alwaysBounceHorizontal = false return view }() private lazy var tagStackView: UIStackView = { let view = UIStackView() view.axis = .horizontal view.alignment = .fill view.spacing = 6 view.addArrangedSubview(tagSpacerView) return view }() private lazy var tagSpacerView: UIView = { let view = UIView() view.setContentHuggingPriority(.defaultLow, for: .horizontal) view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return view }() private func configureTags(_ names: [String]) { tagStackView.arrangedSubviews.filter { $0 !== tagSpacerView }.forEach { tagStackView.removeArrangedSubview($0) $0.removeFromSuperview() } for name in names { let label = PaddingLabel() label.text = name label.insets = UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7) label.font = .systemFont(ofSize: 10, weight: .medium) label.textColor = UIColor(hexStr: "#D5923F") label.backgroundColor = UIColor(hexStr: "#FFF9DD") label.cornerRadius = 4 label.clipsToBounds = true label.setContentCompressionResistancePriority(.required, for: .horizontal) tagStackView.addArrangedSubview(label) } tagScrollView.isHidden = names.isEmpty } private static func orderedPoints(_ points: [SchedulePointModel]) -> [SchedulePointModel] { guard points.count > 1 else { return points } let sequences = points.map(\.sequence) guard Set(sequences).count == points.count else { return points } return points.sorted { $0.sequence < $1.sequence } } private static func locationName(_ point: SchedulePointModel?, maximumCharacters: Int? = nil) -> String { let name: String if let point, !point.street.isEmpty { name = point.street } else if let point, !point.formatted_address.isEmpty { name = point.formatted_address } else { name = "暂无地点" } guard let maximumCharacters, name.count > maximumCharacters else { return name } return String(name.prefix(maximumCharacters)) + "..." } private static func durationText(from start: SchedulePointModel?, to end: SchedulePointModel?) -> String { guard let start, let end, start.id != end.id, end.expected_timestamp > start.expected_timestamp else { return "--" } let minutes = max(1, (end.expected_timestamp - start.expected_timestamp) / 60_000) let hours = minutes / 60 let remainder = minutes % 60 return hours > 0 ? "\(hours)小时\(remainder)分" : "\(minutes)分钟" } } private final class GroupItineraryDashLineView: UIView { override func layoutSubviews() { super.layoutSubviews() layer.sublayers?.forEach { if $0 is CAShapeLayer { $0.removeFromSuperlayer() } } let line = CAShapeLayer() line.strokeColor = UIColor(hexStr: "#D6D6D6").cgColor line.lineWidth = 1.5 line.lineDashPattern = [6, 6] let path = UIBezierPath() path.move(to: CGPoint(x: 0, y: bounds.midY)) path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.midY)) line.path = path.cgPath layer.addSublayer(line) } } private final class PaddingLabel: UILabel { var insets = UIEdgeInsets.zero override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: insets)) } override var intrinsicContentSize: CGSize { let size = super.intrinsicContentSize return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom) } }