// // PigeonMessageHistoryView.swift // QuickLocation // import UIKit import Kingfisher enum PigeonHistoryKind { case image case voice } struct PigeonHistoryItem { let id: String let dateText: String let timeText: String let kind: PigeonHistoryKind let caption: String let image: UIImage? let mediaURL: URL? let senderAvatar: UIImage? let receiverAvatars: [UIImage] let duration: TimeInterval } final class PigeonMessageHistoryView: UIView { private let navView = BaseNavigationView(title: "传书记录") let tableView: UITableView = { let tableView = UITableView(frame: .zero, style: .plain) tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.showsVerticalScrollIndicator = false tableView.estimatedRowHeight = 280 tableView.rowHeight = UITableView.automaticDimension tableView.contentInsetAdjustmentBehavior = .never tableView.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 10, right: 0) tableView.clipsToBounds = true tableView.alwaysBounceVertical = true return tableView }() private let sheetMaskLayer = CAShapeLayer() private let clipOverflow: CGFloat = 12 private let sheetView: UIView = { let view = UIView() view.backgroundColor = .clear view.clipsToBounds = true return view }() private let sheetBackground: UIView = { let view = UIView() view.backgroundColor = .white view.layer.cornerRadius = 24 view.clipsToBounds = true return view }() private let footerView = UIView() private let footerIconView = UIImageView(image: UIImage(named: "PigeonMessage/history_info")) private let footerLabel: UILabel = { let label = UILabel() label.text = "飞鸽传书记录系统只保留7天哦" label.font = .systemFont(ofSize: 12, weight: .regular) label.textColor = UIColor(hexStr: "#9399A1") return label }() private let footerStack: UIStackView = { let stack = UIStackView() stack.axis = .horizontal stack.alignment = .center stack.spacing = 6 return stack }() override init(frame: CGRect) { super.init(frame: frame) setupUI() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { backgroundColor = UIColor(hexStr: "#D2F2FF") addSubview(navView) addSubview(sheetBackground) addSubview(sheetView) sheetView.addSubview(tableView) sheetView.addSubview(footerView) footerView.addSubview(footerStack) footerStack.addArrangedSubview(footerIconView) footerStack.addArrangedSubview(footerLabel) footerIconView.contentMode = .scaleAspectFit footerView.backgroundColor = .white footerView.layer.cornerRadius = 24 footerView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] footerView.clipsToBounds = true navView.layoutChain .edges(excludingEdge: .bottom) .height(kNaviHeight) sheetBackground.layoutChain .topToBottomOfView(navView, offset: 10) .left(16) .right(16) .bottom(max(kSafeBottomMargin, 8)) sheetView.layoutChain .topToView(sheetBackground) .leftToView(sheetBackground, offset: -clipOverflow) .rightToView(sheetBackground) .bottomToView(sheetBackground) footerView.layoutChain .left(clipOverflow) .right() .bottom() .height(52) footerIconView.layoutChain .width(12) .height(12) footerStack.layoutChain .top(16) .centerX() tableView.layoutChain .top() .left() .right() .bottomToTopOfView(footerView) sheetView.layer.mask = sheetMaskLayer } override func layoutSubviews() { super.layoutSubviews() updateSheetMask() } private func updateSheetMask() { let bounds = sheetView.bounds guard bounds.width > 0, bounds.height > 0 else { return } let paperRect = CGRect( x: clipOverflow, y: 0, width: bounds.width - clipOverflow, height: bounds.height ) let path = UIBezierPath(roundedRect: paperRect, cornerRadius: 24) path.append(UIBezierPath( rect: CGRect(x: 0, y: 20, width: clipOverflow + 8, height: max(bounds.height - 40, 0)) )) sheetMaskLayer.frame = bounds sheetMaskLayer.path = path.cgPath } } final class PigeonHistoryCell: UITableViewCell { static let reuseId = "PigeonHistoryCell" var onTogglePlay: (() -> Void)? private let clipView = UIImageView(image: UIImage(named: "PigeonMessage/history_clip")) private let dateLabel = UILabel() private let timeLabel = UILabel() private let cardView = UIView() private let avatarStackView = PigeonHistoryAvatarStackView() private let titleLabel = UILabel() private let mediaStack = UIStackView() private let imageContainer = UIView() private let photoView = UIImageView() private let captionBackground = UIView() private let captionLabel = UILabel() private let voiceBar = UIView() private let playButton = UIButton(type: .custom) private let waveformView = ReceiveVoiceWaveformView() private let voiceTimeLabel = UILabel() 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") } func configure( item: PigeonHistoryItem, isPlaying: Bool, elapsed: TimeInterval ) { dateLabel.text = item.dateText timeLabel.text = item.timeText avatarStackView.configure(sender: item.senderAvatar, receivers: item.receiverAvatars) titleLabel.attributedText = Self.makeTitle(kind: item.kind) let isVoice = item.kind == .voice imageContainer.isHidden = isVoice voiceBar.isHidden = !isVoice photoView.kf.cancelDownloadTask() if let image = item.image, !isVoice { photoView.image = image } else if let mediaURL = item.mediaURL, !isVoice { photoView.kf.setImage(with: mediaURL, placeholder: nil) } else { photoView.image = nil } let caption = item.caption.trimmingCharacters(in: .whitespacesAndNewlines) captionBackground.isHidden = caption.isEmpty captionLabel.text = caption.isEmpty ? "" : "留言:\(String(caption.prefix(20)))" applyPlayback(isPlaying: isPlaying, elapsed: elapsed, duration: item.duration) } func applyPlayback(isPlaying: Bool, elapsed: TimeInterval, duration: TimeInterval) { playButton.isSelected = isPlaying let safeDuration = max(duration, 0.01) waveformView.progress = CGFloat(elapsed / safeDuration) let current = String(format: "%02d″", Int(elapsed.rounded(.down))) let total = String(format: "%02d″", Int(duration.rounded(.down))) let text = "\(current)/\(total)" let attributed = NSMutableAttributedString( string: text, attributes: [ .foregroundColor: UIColor(hexStr: "#86D9F8"), .font: UIFont.systemFont(ofSize: 13, weight: .medium) ] ) attributed.addAttribute( .foregroundColor, value: UIColor(hexStr: "#00ADFE"), range: NSRange(location: 0, length: current.utf16.count) ) voiceTimeLabel.attributedText = attributed } private func setupUI() { backgroundColor = .clear contentView.backgroundColor = .clear selectionStyle = .none clipsToBounds = false contentView.clipsToBounds = false clipView.contentMode = .scaleAspectFit dateLabel.font = .systemFont(ofSize: 16, weight: .medium) dateLabel.textColor = UIColor(hexStr: "#293445") timeLabel.font = .systemFont(ofSize: 13, weight: .regular) timeLabel.textColor = UIColor(hexStr: "#767676") timeLabel.textAlignment = .right cardView.backgroundColor = UIColor(hexStr: "#F5FCFF") cardView.layer.cornerRadius = 20 cardView.clipsToBounds = true titleLabel.textAlignment = .center mediaStack.axis = .vertical mediaStack.alignment = .fill mediaStack.spacing = 0 photoView.contentMode = .scaleAspectFill photoView.clipsToBounds = true photoView.layer.cornerRadius = 18 photoView.backgroundColor = UIColor(hexStr: "#D9D9D9") photoView.isUserInteractionEnabled = false captionBackground.backgroundColor = UIColor.white.withAlphaComponent(0.78) captionBackground.layer.cornerRadius = 12 captionBackground.clipsToBounds = true captionLabel.font = .systemFont(ofSize: 12, weight: .medium) captionLabel.textColor = UIColor(hexStr: "#293445") captionLabel.numberOfLines = 2 voiceBar.backgroundColor = .white voiceBar.layer.cornerRadius = 18 playButton.setImage(UIImage(named: "PigeonMessage/history_play"), for: .normal) playButton.setImage(UIImage(named: "PigeonMessage/history_pause"), for: .selected) playButton.adjustsImageWhenHighlighted = false playButton.addTarget(self, action: #selector(tapPlay), for: .touchUpInside) contentView.addSubview(clipView) contentView.addSubview(dateLabel) contentView.addSubview(timeLabel) contentView.addSubview(cardView) cardView.addSubview(avatarStackView) cardView.addSubview(titleLabel) cardView.addSubview(mediaStack) mediaStack.addArrangedSubview(imageContainer) mediaStack.addArrangedSubview(voiceBar) imageContainer.addSubview(photoView) imageContainer.addSubview(captionBackground) captionBackground.addSubview(captionLabel) voiceBar.addSubview(playButton) voiceBar.addSubview(waveformView) voiceBar.addSubview(voiceTimeLabel) clipView.layoutChain .top(14) .left(2) .width(31) .height(16) dateLabel.layoutChain .centerY(clipView) .leftToRightOfView(clipView, offset: 8) timeLabel.layoutChain .centerY(clipView) .right(20) cardView.layoutChain .topToBottomOfView(clipView, offset: 8) .left(27) .right(15) .bottom(12) avatarStackView.layoutChain .top(16) .centerX() .height(40) titleLabel.layoutChain .topToBottomOfView(avatarStackView, offset: 10) .left(16) .right(16) mediaStack.layoutChain .topToBottomOfView(titleLabel, offset: 12) .left(12) .right(12) .bottom(14) imageContainer.layoutChain.height(118) photoView.layoutChain .centerX() .top() .width(200) .height(118) captionBackground.layoutChain .leftToView(photoView, offset: 10) .rightToView(photoView, offset: -10) .bottomToView(photoView, offset: -8) captionLabel.layoutChain .top(8) .left(10) .right(10) .bottom(8) voiceBar.layoutChain.height(52) playButton.layoutChain .left(12) .centerY() .width(26) .height(26) voiceTimeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) voiceTimeLabel.layoutChain .right(12) .centerY() waveformView.layoutChain .leftToRightOfView(playButton, offset: 10) .rightToLeftOfView(voiceTimeLabel, offset: -10) .centerY() .height(22) } @objc private func tapPlay() { onTogglePlay?() } private static func makeTitle(kind: PigeonHistoryKind) -> NSAttributedString { let suffix = kind == .image ? "图片" : "语音" let text = "你 发给TA的 \(suffix)" let attributed = NSMutableAttributedString( string: text, attributes: [ .font: UIFont.systemFont(ofSize: 15, weight: .medium), .foregroundColor: UIColor(hexStr: "#1C2430") ] ) attributed.addAttributes( [ .font: UIFont.systemFont(ofSize: 15, weight: .regular), .foregroundColor: UIColor(hexStr: "#5E6874") ], range: NSRange(location: 2, length: 5) ) attributed.addAttributes( [ .font: UIFont.systemFont(ofSize: 15, weight: .medium), .foregroundColor: UIColor(hexStr: "#00ADFE") ], range: (text as NSString).range(of: suffix) ) return attributed } } final class PigeonHistoryAvatarStackView: UIView { private let senderView = UIImageView() private let arrowView = UIImageView(image: UIImage(named: "PigeonMessage/history_arrow")) private var receiverViews: [UIImageView] = [] private var widthConstraint: NSLayoutConstraint? override init(frame: CGRect) { super.init(frame: frame) senderView.contentMode = .scaleAspectFill senderView.clipsToBounds = true senderView.layer.cornerRadius = 14 senderView.layer.borderWidth = 2 senderView.layer.borderColor = UIColor.white.cgColor arrowView.contentMode = .scaleAspectFit addSubview(senderView) addSubview(arrowView) translatesAutoresizingMaskIntoConstraints = false widthConstraint = widthAnchor.constraint(equalToConstant: 118) widthConstraint?.isActive = true } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(sender: UIImage?, receivers: [UIImage]) { senderView.image = sender ?? UIImage(named: "UserIcon/1") receiverViews.forEach { $0.removeFromSuperview() } receiverViews = receivers.map { image in let view = UIImageView(image: image) view.contentMode = .scaleAspectFill view.clipsToBounds = true view.layer.cornerRadius = 14 view.layer.borderWidth = 2 view.layer.borderColor = UIColor.white.cgColor addSubview(view) return view } invalidateIntrinsicContentSize() widthConstraint?.constant = intrinsicContentSize.width setNeedsLayout() } override var intrinsicContentSize: CGSize { let receiverCount = max(receiverViews.count, 1) let receiversWidth = 40 + CGFloat(receiverCount - 1) * 22 return CGSize(width: 40 + 10 + 18 + 10 + receiversWidth, height: 40) } override func layoutSubviews() { super.layoutSubviews() senderView.frame = CGRect(x: 0, y: 0, width: 40, height: 40) arrowView.frame = CGRect(x: 50, y: 14, width: 18, height: 12) var x: CGFloat = 78 for view in receiverViews { view.frame = CGRect(x: x, y: 0, width: 40, height: 40) x += 22 } for (index, view) in receiverViews.enumerated() { view.layer.zPosition = CGFloat(receiverViews.count - index) } } }