// // MailboxDetailViewController.swift // QuickLocation // import UIKit import RxSwift import Lottie final class MailboxDetailViewController: UIViewController { private var messages: [MailboxMessage] private var currentIndex: Int private var currentMessage: MailboxMessage private let disposeBag = DisposeBag() private let detailView = MailboxDetailView() private var isLoading = false var onMessageRead: ((MailboxMessage) -> Void)? init(messages: [MailboxMessage], initialMessage: MailboxMessage) { self.messages = messages self.currentMessage = initialMessage self.currentIndex = messages.firstIndex(where: { $0.id == initialMessage.id }) ?? 0 super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen modalTransitionStyle = .crossDissolve } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { view = detailView } override func viewDidLoad() { super.viewDidLoad() detailView.closeButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside) detailView.previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside) detailView.nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside) renderCurrentMessage() } private func renderCurrentMessage() { detailView.configure( message: currentMessage, canGoPrevious: currentIndex > 0, canGoNext: currentIndex + 1 < messages.count ) } private func loadMessage(at index: Int) { guard messages.indices.contains(index), !isLoading else { return } let cachedMessage = messages[index] isLoading = true detailView.setControlsEnabled(false) DLToast.showLoading() UserService.mailboxDetail(id: cachedMessage.id) .subscribe(onNext: { [weak self] response in guard let self else { return } DLToast.dismiss() self.isLoading = false guard let responseMessage = response.data else { self.detailView.setControlsEnabled(true) DLToast.showError(text: response.message ?? "消息详情加载失败") return } var detail = self.mergeDetail(responseMessage, fallback: cachedMessage) detail.isRead = true self.messages[index] = detail self.currentIndex = index self.currentMessage = detail self.onMessageRead?(detail) self.renderCurrentMessage() }, onError: { [weak self] error in DLToast.dismiss() self?.isLoading = false self?.detailView.setControlsEnabled(true) DLToast.showError(error) }) .disposed(by: disposeBag) } private func mergeDetail(_ detail: MailboxMessage, fallback: MailboxMessage) -> MailboxMessage { var merged = detail if merged.id == 0 { merged.id = fallback.id } if merged.title.isEmpty { merged.title = fallback.title } if merged.content.isEmpty { merged.content = fallback.content } if merged.senderId.isEmpty { merged.senderId = fallback.senderId } if merged.senderIcon.isEmpty { merged.senderIcon = fallback.senderIcon } if merged.modifyTime.isEmpty { merged.modifyTime = fallback.modifyTime } if MailboxCategory(rawValue: merged.type) == nil { merged.type = fallback.type } return merged } @objc private func closeAction() { dismiss(animated: true) } @objc private func previousAction() { loadMessage(at: currentIndex - 1) } @objc private func nextAction() { loadMessage(at: currentIndex + 1) } } private final class MailboxDetailView: UIView { let previousButton = UIButton(type: .custom) let closeButton = UIButton(type: .custom) let nextButton = UIButton(type: .custom) private let cardView = UIView() private let cardBackgroundView = UIImageView() private let birdImageView = UIImageView(image: UIImage(named: "Mailbox/detail_bird")) private let typeLabel = UILabel() private let dividerView = UIView() private let titleLabel = UILabel() private let scrollView = UIScrollView() private let contentContainerView = UIView() private let contentLabel = UILabel() private let lottieView = LottieAnimationView() private let quickMessageLabel = UILabel() private var animationName: String? private var contentBottomConstraint: NSLayoutConstraint? private var cardHeightConstraint: NSLayoutConstraint? override init(frame: CGRect) { super.init(frame: frame) setupUI() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(message: MailboxMessage, canGoPrevious: Bool, canGoNext: Bool) { typeLabel.text = message.category.detailTitle titleLabel.text = message.title configureContent(message: message) previousButton.isEnabled = canGoPrevious nextButton.isEnabled = canGoNext updateButtonAppearance() } func setControlsEnabled(_ enabled: Bool) { previousButton.isUserInteractionEnabled = enabled nextButton.isUserInteractionEnabled = enabled closeButton.isUserInteractionEnabled = enabled previousButton.alpha = enabled ? (previousButton.isEnabled ? 1 : 0.35) : 0.35 nextButton.alpha = enabled ? (nextButton.isEnabled ? 1 : 0.35) : 0.35 closeButton.alpha = enabled ? 1 : 0.55 } private func setupUI() { backgroundColor = UIColor.black.withAlphaComponent(0.68) cardView.layer.cornerRadius = 31 cardView.clipsToBounds = true cardBackgroundView.image = UIImage(named: "Mailbox/detail_card")?.resizableImage( withCapInsets: UIEdgeInsets(top: 70, left: 70, bottom: 70, right: 70), resizingMode: .stretch ) cardBackgroundView.contentMode = .scaleToFill birdImageView.contentMode = .scaleAspectFit typeLabel.font = .systemFont(ofSize: 18, weight: .medium) typeLabel.textColor = UIColor(hexStr: "#14324E") typeLabel.textAlignment = .center dividerView.backgroundColor = UIColor(hexStr: "#80D8FF", alpha: 0.55) titleLabel.font = .systemFont(ofSize: 18, weight: .medium) titleLabel.textColor = UIColor(hexStr: "#333333") titleLabel.textAlignment = .center titleLabel.numberOfLines = 0 scrollView.showsVerticalScrollIndicator = false scrollView.alwaysBounceVertical = false contentLabel.font = .systemFont(ofSize: 15) contentLabel.textColor = UIColor(hexStr: "#858585") contentLabel.textAlignment = .center contentLabel.numberOfLines = 0 lottieView.contentMode = .scaleAspectFit lottieView.loopMode = .loop lottieView.backgroundBehavior = .pauseAndRestore quickMessageLabel.font = .systemFont(ofSize: 15) quickMessageLabel.textColor = UIColor(hexStr: "#52606D") quickMessageLabel.textAlignment = .center quickMessageLabel.numberOfLines = 0 quickMessageLabel.backgroundColor = UIColor(hexStr: "#DFF5FF") quickMessageLabel.layer.cornerRadius = 9 quickMessageLabel.clipsToBounds = true configureControlButton(previousButton, title: "上一条") configureControlButton(nextButton, title: "下一条") closeButton.setImage(UIImage(named: "Mailbox/detail_close"), for: .normal) closeButton.accessibilityLabel = "关闭" addSubview(cardView) cardView.addSubview(cardBackgroundView) addSubview(birdImageView) cardView.addSubview(typeLabel) cardView.addSubview(dividerView) cardView.addSubview(titleLabel) cardView.addSubview(scrollView) scrollView.addSubview(contentContainerView) contentContainerView.addSubview(contentLabel) contentContainerView.addSubview(lottieView) contentContainerView.addSubview(quickMessageLabel) addSubview(previousButton) addSubview(closeButton) addSubview(nextButton) [cardView, cardBackgroundView, birdImageView, typeLabel, dividerView, titleLabel, scrollView, contentContainerView, contentLabel, lottieView, quickMessageLabel, previousButton, closeButton, nextButton].forEach { $0.translatesAutoresizingMaskIntoConstraints = false } let cardWidth = min(kScreenWidth - 48, 360) let cardHeightConstraint = cardView.heightAnchor.constraint(equalToConstant: 245) self.cardHeightConstraint = cardHeightConstraint NSLayoutConstraint.activate([ cardView.centerXAnchor.constraint(equalTo: centerXAnchor), cardView.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -32), cardView.widthAnchor.constraint(equalToConstant: cardWidth), cardHeightConstraint, cardBackgroundView.topAnchor.constraint(equalTo: cardView.topAnchor), cardBackgroundView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor), cardBackgroundView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor), cardBackgroundView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor), birdImageView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 36), birdImageView.bottomAnchor.constraint(equalTo: cardView.topAnchor, constant: 35), birdImageView.widthAnchor.constraint(equalToConstant: 100), birdImageView.heightAnchor.constraint(equalToConstant: 74), typeLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 49), typeLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 26), typeLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -26), dividerView.topAnchor.constraint(equalTo: typeLabel.bottomAnchor, constant: 15), dividerView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 23), dividerView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -23), dividerView.heightAnchor.constraint(equalToConstant: 0.5), titleLabel.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: 20), titleLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 30), titleLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -30), scrollView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 13), scrollView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 28), scrollView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -28), scrollView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -25), contentContainerView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), contentContainerView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), contentContainerView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), contentContainerView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), contentContainerView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), contentLabel.topAnchor.constraint(equalTo: contentContainerView.topAnchor), contentLabel.leadingAnchor.constraint(equalTo: contentContainerView.leadingAnchor), contentLabel.trailingAnchor.constraint(equalTo: contentContainerView.trailingAnchor), lottieView.topAnchor.constraint(equalTo: contentLabel.bottomAnchor, constant: 10), lottieView.centerXAnchor.constraint(equalTo: contentContainerView.centerXAnchor), lottieView.widthAnchor.constraint(equalToConstant: 116), lottieView.heightAnchor.constraint(equalToConstant: 116), quickMessageLabel.topAnchor.constraint(equalTo: contentLabel.bottomAnchor, constant: 14), quickMessageLabel.centerXAnchor.constraint(equalTo: contentContainerView.centerXAnchor), quickMessageLabel.leadingAnchor.constraint(greaterThanOrEqualTo: contentContainerView.leadingAnchor, constant: 20), quickMessageLabel.trailingAnchor.constraint(lessThanOrEqualTo: contentContainerView.trailingAnchor, constant: -20), quickMessageLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 40), previousButton.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 20), previousButton.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 34), previousButton.heightAnchor.constraint(equalToConstant: 40), nextButton.topAnchor.constraint(equalTo: previousButton.topAnchor), nextButton.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -34), nextButton.heightAnchor.constraint(equalTo: previousButton.heightAnchor), closeButton.centerXAnchor.constraint(equalTo: centerXAnchor), closeButton.centerYAnchor.constraint(equalTo: previousButton.centerYAnchor), closeButton.widthAnchor.constraint(equalToConstant: 24), closeButton.heightAnchor.constraint(equalToConstant: 24) ]) } private func configureControlButton(_ button: UIButton, title: String) { button.setTitle(title, for: .normal) button.setTitleColor(.white, for: .normal) button.setTitleColor(UIColor.white.withAlphaComponent(0.45), for: .disabled) button.titleLabel?.font = .systemFont(ofSize: 16) } private func configureContent(message: MailboxMessage) { animationName = nil lottieView.stop() lottieView.animation = nil lottieView.isHidden = true quickMessageLabel.isHidden = true contentLabel.text = message.content cardHeightConstraint?.constant = message.content.count > 80 ? 300 : 245 guard message.category == .interaction, let payload = parsePayload(message.content) else { updateContentBottomAnchor(visibleView: contentLabel) return } contentLabel.text = payload.prefix switch payload.kind { case let .animation(name): cardHeightConstraint?.constant = 330 animationName = name lottieView.isHidden = false updateContentBottomAnchor(visibleView: lottieView) EmojiPanelCell.loadAnimation(for: name) { [weak self] animation in guard let self, self.animationName == name else { return } guard let animation else { self.showFallbackContent(message.content) return } self.lottieView.animation = animation self.lottieView.currentProgress = 0 self.lottieView.play() } case let .quickMessage(text): cardHeightConstraint?.constant = 265 quickMessageLabel.text = " \(text) " quickMessageLabel.isHidden = false updateContentBottomAnchor(visibleView: quickMessageLabel) } } private func updateContentBottomAnchor(visibleView: UIView) { contentBottomConstraint?.isActive = false contentBottomConstraint = visibleView.bottomAnchor.constraint(equalTo: contentContainerView.bottomAnchor) contentBottomConstraint?.isActive = true } private func showFallbackContent(_ content: String) { animationName = nil lottieView.stop() lottieView.animation = nil lottieView.isHidden = true quickMessageLabel.isHidden = true contentLabel.text = content updateContentBottomAnchor(visibleView: contentLabel) } private func parsePayload(_ content: String) -> MailboxPayload? { guard let opening = content.firstIndex(of: "["), let closing = content[opening...].firstIndex(of: "]"), let lastOpening = content.lastIndex(of: "[") else { return nil } let codeStart = content.index(after: opening) let code = String(content[codeStart.. 1, let prefixCode = code.first else { return nil } let value = String(code.dropFirst()) let prefix = String(content[..