// // WildPhotographerReportSubmitViewController.swift // suixinkan // import AVFoundation import PhotosUI import QuickLook import SnapKit import UIKit import UniformTypeIdentifiers /// 提交举报页面,提供类型、证据、说明、定位和联系方式表单。 final class WildPhotographerReportSubmitViewController: BaseViewController { private let viewModel: WildPhotographerReportSubmitViewModel private let homeViewModel: WildPhotographerReportHomeViewModel private let api: any WildPhotographerReportServing private let uploader: any WildReportAttachmentUploading private let scrollView = UIScrollView() private let contentStack = UIStackView() private let descriptionView = UITextView() private let contactField = UITextField() private let submitButton = UIButton(type: .system) private var pickerTarget: WildReportPickerTarget = .image private var previewURL: URL? private weak var activeInputView: UIView? init( homeViewModel: WildPhotographerReportHomeViewModel, locationProvider: any LocationProviding = LocationProvider.shared, api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService ) { self.homeViewModel = homeViewModel self.api = api self.uploader = uploader self.viewModel = WildPhotographerReportSubmitViewModel( homeViewModel: homeViewModel, locationProvider: locationProvider ) super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { NotificationCenter.default.removeObserver(self) } override func setupNavigationBar() { title = "提交举报" } override func setupUI() { view.backgroundColor = AppColor.pageBackgroundSoft scrollView.keyboardDismissMode = .onDrag scrollView.showsVerticalScrollIndicator = false contentStack.axis = .vertical contentStack.spacing = AppSpacing.sm descriptionView.font = .systemFont(ofSize: 15) descriptionView.textColor = AppColor.textPrimary descriptionView.backgroundColor = .clear descriptionView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8) descriptionView.delegate = self contactField.placeholder = "请输入摄影师微信号或手机号(选填)" contactField.font = .app(.body) contactField.textColor = AppColor.textPrimary contactField.backgroundColor = UIColor(hex: 0xFBFCFE) contactField.layer.cornerRadius = 10 contactField.layer.borderWidth = 1 contactField.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor contactField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) contactField.leftViewMode = .always contactField.delegate = self contactField.addTarget(self, action: #selector(contactChanged), for: .editingChanged) submitButton.setTitle("提交举报", for: .normal) submitButton.titleLabel?.font = .systemFont(ofSize: 22, weight: .bold) submitButton.setTitleColor(.white, for: .normal) submitButton.backgroundColor = AppColor.primary submitButton.layer.cornerRadius = 14 submitButton.layer.shadowColor = AppColor.primary.cgColor submitButton.layer.shadowOpacity = 0.22 submitButton.layer.shadowRadius = 12 submitButton.layer.shadowOffset = CGSize(width: 0, height: 6) submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) view.addSubview(scrollView) scrollView.addSubview(contentStack) let dismissTap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) dismissTap.cancelsTouchesInView = false dismissTap.delegate = self scrollView.addGestureRecognizer(dismissTap) registerKeyboardNotifications() rebuildContent() } override func setupConstraints() { scrollView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.bottom.equalToSuperview() } contentStack.snp.makeConstraints { make in make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 14, left: 18, bottom: 26, right: 18)) make.width.equalTo(scrollView.frameLayoutGuide).offset(-36) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.rebuildContent() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } viewModel.onSubmitSuccess = { [weak self] result in Task { @MainActor in guard let self else { return } self.navigationController?.pushViewController( WildPhotographerReportSuccessViewController( result: result, homeViewModel: self.homeViewModel ), animated: true ) } } } override func viewDidLoad() { super.viewDidLoad() viewModel.loadInitialLocationIfNeeded() Task { await viewModel.loadReportTypes(api: api) } } @MainActor private func rebuildContent() { contentStack.arrangedSubviews.forEach { view in contentStack.removeArrangedSubview(view) view.removeFromSuperview() } contentStack.addArrangedSubview(makeRealNameBanner()) contentStack.addArrangedSubview(makeTypeCard()) contentStack.addArrangedSubview(makeEvidenceCard()) contentStack.addArrangedSubview(makeDescriptionCard()) contentStack.addArrangedSubview(makeLocationCard()) contentStack.addArrangedSubview(makeContactCard()) let paymentCard = makePaymentCard() contentStack.addArrangedSubview(paymentCard) contentStack.setCustomSpacing(16, after: paymentCard) contentStack.addArrangedSubview(submitButton) submitButton.setTitle(viewModel.isSubmitting ? viewModel.submitProgressText : "提交举报", for: .normal) submitButton.isEnabled = !viewModel.isSubmitting submitButton.alpha = viewModel.isSubmitting ? 0.78 : 1 submitButton.snp.remakeConstraints { make in make.height.equalTo(56) } if activeInputView?.isFirstResponder == true { scrollActiveInputIntoView(animated: false) } } private func makeRealNameBanner() -> UIView { let banner = UIView() banner.backgroundColor = UIColor(hex: 0xF6FAFF) banner.layer.cornerRadius = 16 banner.layer.borderWidth = 1 banner.layer.borderColor = UIColor(hex: 0xBFD7FF).cgColor let iconWrap = UIView() iconWrap.backgroundColor = AppColor.primary.withAlphaComponent(0.10) iconWrap.layer.cornerRadius = 17 let icon = UIImageView(image: UIImage(systemName: "checkmark.shield.fill")) icon.tintColor = AppColor.primary iconWrap.addSubview(icon) icon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(22) } let message = UILabel() message.numberOfLines = 0 message.attributedText = realNameBannerText() banner.addSubview(iconWrap) banner.addSubview(message) iconWrap.snp.makeConstraints { make in make.top.leading.equalToSuperview().offset(16) make.size.equalTo(34) make.bottom.lessThanOrEqualToSuperview().inset(16) } message.snp.makeConstraints { make in make.top.trailing.bottom.equalToSuperview().inset(16) make.leading.equalTo(iconWrap.snp.trailing).offset(12) } return banner } private func makeTypeCard() -> UIView { let card = sectionCard(index: 1, title: "举报类型") if viewModel.isLoadingReportTypes { let loading = WildReportUI.label("正在同步举报类型...", font: .systemFont(ofSize: 13), color: AppColor.textSecondary) card.stack.addArrangedSubview(loading) } let grid = UIStackView() grid.axis = .vertical grid.spacing = 8 let types = viewModel.reportTypes.isEmpty ? WildReportType.fallbackOptions : viewModel.reportTypes stride(from: 0, to: types.count, by: 3).forEach { startIndex in let rowTypes = Array(types[startIndex ..< min(startIndex + 3, types.count)]) let row = UIStackView() row.axis = .horizontal row.spacing = 8 row.distribution = .fillEqually rowTypes.forEach { type in let button = WildReportTypeOptionButton() button.apply(type: type, isSelected: type == viewModel.selectedReportType) button.addAction(UIAction { [weak self] _ in self?.viewModel.selectReportType(type) }, for: .touchUpInside) button.snp.makeConstraints { make in make.height.equalTo(72) } row.addArrangedSubview(button) } for _ in rowTypes.count..<3 { row.addArrangedSubview(UIView()) } grid.addArrangedSubview(row) } card.stack.addArrangedSubview(grid) return card } private func makeEvidenceCard() -> UIView { let card = sectionCard(index: 2, title: "上传现场证据") card.stack.addArrangedSubview(makeEvidenceUploadBlock( icon: "photo.fill", title: "上传图片", subtitle: "支持 JPG、PNG 格式,最多 9 张", attachments: viewModel.images, maxCount: 9, kind: .image )) card.stack.addArrangedSubview(makeDivider()) card.stack.addArrangedSubview(makeEvidenceUploadBlock( icon: "video.fill", title: "上传视频", subtitle: "支持 MP4 格式,时长不超过 60 秒", attachments: viewModel.videos, maxCount: 3, kind: .video )) return card } private func makeDescriptionCard() -> UIView { let card = sectionCard(index: 3, title: "举报说明") descriptionView.text = viewModel.description let inputContainer = UIView() inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE) inputContainer.layer.cornerRadius = 12 inputContainer.layer.borderWidth = 1 inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor let placeholder = UILabel() placeholder.text = "请描述摄影师的位置、特征、行为..." placeholder.font = .systemFont(ofSize: 15) placeholder.textColor = UIColor(hex: 0x9CA3AF) placeholder.isHidden = !viewModel.description.isEmpty let count = WildReportUI.label("\(viewModel.description.count)/500", font: .systemFont(ofSize: 14), color: UIColor(hex: 0x6B7280)) count.textAlignment = .right inputContainer.addSubview(descriptionView) inputContainer.addSubview(placeholder) inputContainer.addSubview(count) descriptionView.snp.makeConstraints { make in make.edges.equalToSuperview() make.height.equalTo(132) } placeholder.snp.makeConstraints { make in make.top.equalToSuperview().offset(18) make.leading.equalToSuperview().offset(14) make.trailing.lessThanOrEqualToSuperview().inset(14) } count.snp.makeConstraints { make in make.trailing.bottom.equalToSuperview().inset(12) } card.stack.addArrangedSubview(inputContainer) return card } private func makeLocationCard() -> UIView { let card = sectionCard(index: 4, title: "举报位置") let container = UIView() container.backgroundColor = UIColor(hex: 0xF6FAFF) container.layer.cornerRadius = 12 container.layer.borderWidth = 1 container.layer.borderColor = UIColor(hex: 0xC8DDFF).cgColor let iconWrap = UIView() iconWrap.backgroundColor = AppColor.primary.withAlphaComponent(0.10) iconWrap.layer.cornerRadius = 21 let icon = UIImageView(image: UIImage(systemName: "mappin.circle.fill")) icon.tintColor = AppColor.primary iconWrap.addSubview(icon) icon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(30) } let textStack = UIStackView() textStack.axis = .vertical textStack.spacing = 5 let title = WildReportUI.label(viewModel.locationTitleText, font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textPrimary, lines: 2) title.adjustsFontSizeToFitWidth = true title.minimumScaleFactor = 0.78 let subtitle = WildReportUI.label(viewModel.locationSubtitleText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 0) textStack.addArrangedSubview(title) textStack.addArrangedSubview(subtitle) let button = UIButton(type: .system) button.setTitle(viewModel.isUpdatingLocation ? "定位中" : "更新定位", for: .normal) button.titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold) button.setTitleColor(AppColor.primary, for: .normal) button.backgroundColor = .white button.layer.cornerRadius = 17 button.layer.borderWidth = 1 button.layer.borderColor = AppColor.primary.cgColor button.isEnabled = !viewModel.isUpdatingLocation button.alpha = viewModel.isUpdatingLocation ? 0.6 : 1 button.addTarget(self, action: #selector(refreshLocationTapped), for: .touchUpInside) container.addSubview(iconWrap) container.addSubview(textStack) container.addSubview(button) iconWrap.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(12) make.size.equalTo(42) } button.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(12) make.centerY.equalToSuperview() make.width.equalTo(82) make.height.equalTo(34) } textStack.snp.makeConstraints { make in make.leading.equalTo(iconWrap.snp.trailing).offset(12) make.trailing.equalTo(button.snp.leading).offset(-8) make.centerY.equalToSuperview() make.top.greaterThanOrEqualToSuperview().offset(12) make.bottom.lessThanOrEqualToSuperview().inset(12) } card.stack.addArrangedSubview(container) return card } private func makeContactCard() -> UIView { let card = sectionCard(index: 5, title: "摄影师微信号/手机号", optional: true) contactField.text = viewModel.contact card.stack.addArrangedSubview(contactField) contactField.snp.makeConstraints { make in make.height.equalTo(48) } return card } private func makePaymentCard() -> UIView { let card = sectionCard(index: 6, title: "线下微信、支付宝截图", optional: true) card.stack.addArrangedSubview(makeEvidenceUploadBlock( icon: "creditcard.fill", title: "上传支付截图", subtitle: "支持 JPG、PNG 格式,最多 3 张", attachments: viewModel.paymentImages, maxCount: 3, kind: .payment )) return card } private func sectionCard(index: Int, title: String, optional: Bool = false) -> WildReportCardView { let card = WildReportCardView(spacing: 18, inset: 16) card.layer.cornerRadius = 16 card.stack.addArrangedSubview(WildReportSectionTitleView(index: index, title: title, optional: optional)) return card } private func makeEvidenceUploadBlock( icon: String, title: String, subtitle: String, attachments: [WildReportAttachment], maxCount: Int, kind: WildReportAttachmentKind ) -> UIView { let block = UIStackView() block.axis = .vertical block.spacing = 12 let header = UIStackView() header.axis = .horizontal header.alignment = .center header.spacing = 12 let iconWrap = UIView() iconWrap.backgroundColor = AppColor.primaryLight iconWrap.layer.cornerRadius = 12 let image = UIImageView(image: UIImage(systemName: icon)) image.tintColor = AppColor.primary iconWrap.addSubview(image) image.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(22) } iconWrap.snp.makeConstraints { make in make.size.equalTo(42) } let textStack = UIStackView() textStack.axis = .vertical textStack.spacing = 3 textStack.addArrangedSubview(WildReportUI.label(title, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary)) textStack.addArrangedSubview(WildReportUI.label(subtitle, font: .systemFont(ofSize: 13), color: AppColor.textSecondary, lines: 0)) header.addArrangedSubview(iconWrap) header.addArrangedSubview(textStack) header.addArrangedSubview(UIView()) block.addArrangedSubview(header) let scroll = UIScrollView() scroll.keyboardDismissMode = .onDrag scroll.showsHorizontalScrollIndicator = false let tiles = UIStackView() tiles.axis = .horizontal tiles.spacing = 10 scroll.addSubview(tiles) tiles.snp.makeConstraints { make in make.edges.equalTo(scroll.contentLayoutGuide).inset(UIEdgeInsets(top: 2, left: 0, bottom: 2, right: 0)) make.height.equalTo(scroll.frameLayoutGuide).offset(-4) } attachments.forEach { attachment in tiles.addArrangedSubview(WildReportAttachmentTileView( attachment: attachment, onPreview: { [weak self] in self?.previewAttachment(attachment) }, onRemove: { [weak self] in self?.viewModel.removeAttachment(attachment) } )) } let addCount = availableAddTileCount(kind: kind, attachments: attachments, maxCount: maxCount) for _ in 0.. UIView { let divider = UIView() divider.backgroundColor = AppColor.border divider.snp.makeConstraints { make in make.height.equalTo(1) } return divider } private func realNameBannerText() -> NSAttributedString { let text = NSMutableAttributedString() [ ("实名登录", AppColor.primary, UIFont.systemFont(ofSize: 16, weight: .bold)), ("后提交,", AppColor.textPrimary, UIFont.systemFont(ofSize: 16, weight: .medium)), ("景区公安", AppColor.primary, UIFont.systemFont(ofSize: 16, weight: .bold)), ("将接收并处理", AppColor.textPrimary, UIFont.systemFont(ofSize: 16, weight: .medium)) ].forEach { value, color, font in text.append(NSAttributedString(string: value, attributes: [ .foregroundColor: color, .font: font ])) } let paragraph = NSMutableParagraphStyle() paragraph.lineSpacing = 4 text.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: text.length)) return text } private func presentMediaPicker(for target: WildReportPickerTarget) { dismissKeyboard() let remaining = remainingSelectionCount(for: target) guard remaining > 0 else { showToast(target.limitMessage) return } pickerTarget = target var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.selectionLimit = remaining configuration.filter = target == .video ? .videos : .images let picker = PHPickerViewController(configuration: configuration) picker.delegate = self present(picker, animated: true) } private func remainingSelectionCount(for target: WildReportPickerTarget) -> Int { switch target { case .image: return max(0, 9 - viewModel.images.count - viewModel.videos.count) case .video: return max(0, min(3 - viewModel.videos.count, 9 - viewModel.images.count - viewModel.videos.count)) case .payment: return max(0, 3 - viewModel.paymentImages.count) } } private func availableAddTileCount( kind: WildReportAttachmentKind, attachments: [WildReportAttachment], maxCount: Int ) -> Int { let ownRemaining = max(0, maxCount - attachments.count) let evidenceRemaining = max(0, 9 - viewModel.images.count - viewModel.videos.count) switch kind { case .image: return min(ownRemaining, evidenceRemaining) > 0 ? 1 : 0 case .video: return min(ownRemaining, evidenceRemaining) > 0 ? 1 : 0 case .payment: return ownRemaining > 0 ? 1 : 0 } } private func previewAttachment(_ attachment: WildReportAttachment) { guard let url = attachment.localFileURL else { showToast("当前附件暂无本地预览") return } previewURL = url let preview = QLPreviewController() preview.dataSource = self present(preview, animated: true) } @objc private func refreshLocationTapped() { viewModel.refreshCurrentLocation() } @objc private func submitTapped() { Task { await viewModel.submitReport( api: api, uploader: uploader, scenicId: AppStore.shared.currentScenicId, scenicName: AppStore.shared.currentScenicName ) } } @objc private func contactChanged() { viewModel.updateContact(contactField.text ?? "") } @objc private func dismissKeyboard() { view.endEditing(true) } private func registerKeyboardNotifications() { NotificationCenter.default.addObserver( self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil ) } @objc private func keyboardWillChangeFrame(_ notification: Notification) { guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } let keyboardFrameInView = view.convert(keyboardFrame, from: nil) let overlap = max(0, view.bounds.maxY - keyboardFrameInView.minY) updateScrollInsets(bottom: overlap + 16, notification: notification) scrollActiveInputIntoView(animated: true) } @objc private func keyboardWillHide(_ notification: Notification) { updateScrollInsets(bottom: 0, notification: notification) } private func updateScrollInsets(bottom: CGFloat, notification: Notification) { let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25 let curveRaw = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt ?? UIView.AnimationOptions.curveEaseInOut.rawValue let options = UIView.AnimationOptions(rawValue: curveRaw << 16) UIView.animate(withDuration: duration, delay: 0, options: options) { self.scrollView.contentInset.bottom = bottom self.scrollView.verticalScrollIndicatorInsets.bottom = bottom } } private func scrollActiveInputIntoView(animated: Bool) { guard let activeInputView else { return } view.layoutIfNeeded() let rect = activeInputView.convert(activeInputView.bounds, to: scrollView).insetBy(dx: 0, dy: -24) scrollView.scrollRectToVisible(rect, animated: animated) } } extension WildPhotographerReportSubmitViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard !results.isEmpty else { return } let target = pickerTarget Task { do { let attachments = try await WildReportLocalMediaLoader.load(results: results, target: target) await MainActor.run { switch target { case .image: viewModel.addImages(attachments) case .video: viewModel.addVideos(attachments) case .payment: viewModel.addPaymentImages(attachments) } } } catch { await MainActor.run { showToast(error.localizedDescription) } } } } } extension WildPhotographerReportSubmitViewController: QLPreviewControllerDataSource { func numberOfPreviewItems(in controller: QLPreviewController) -> Int { previewURL == nil ? 0 : 1 } func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { previewURL! as NSURL } } extension WildPhotographerReportSubmitViewController: UITextViewDelegate { func textViewDidBeginEditing(_ textView: UITextView) { activeInputView = textView DispatchQueue.main.async { [weak self] in self?.scrollActiveInputIntoView(animated: true) } } func textViewDidChange(_ textView: UITextView) { viewModel.updateDescription(textView.text) } func textViewDidEndEditing(_ textView: UITextView) { if activeInputView === textView { activeInputView = nil } } } extension WildPhotographerReportSubmitViewController: UITextFieldDelegate { func textFieldDidBeginEditing(_ textField: UITextField) { activeInputView = textField DispatchQueue.main.async { [weak self] in self?.scrollActiveInputIntoView(animated: true) } } func textFieldDidEndEditing(_ textField: UITextField) { if activeInputView === textField { activeInputView = nil } } } extension WildPhotographerReportSubmitViewController: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { var touchedView = touch.view while let currentView = touchedView { if currentView is UIControl || currentView is UITextView || currentView is UITextField { return false } touchedView = currentView.superview } return true } } /// 提交页本地媒体选择目标。 private enum WildReportPickerTarget: Equatable { case image case video case payment init(kind: WildReportAttachmentKind) { switch kind { case .image: self = .image case .video: self = .video case .payment: self = .payment } } var attachmentKind: WildReportAttachmentKind { switch self { case .image: return .image case .video: return .video case .payment: return .payment } } var titlePrefix: String { switch self { case .image: return "现场图片" case .video: return "现场视频" case .payment: return "支付截图" } } var limitMessage: String { switch self { case .image: return "现场证据最多上传9项" case .video: return "现场视频最多上传3段" case .payment: return "支付截图最多上传3张" } } } /// 提交页 PHPicker 结果加载器,将图片/视频保存为可预览和待上传的本地文件。 private enum WildReportLocalMediaLoader { static func load(results: [PHPickerResult], target: WildReportPickerTarget) async throws -> [WildReportAttachment] { var attachments: [WildReportAttachment] = [] for result in results { let attachment = target == .video ? try await loadVideo(from: result.itemProvider, target: target) : try await loadImage(from: result.itemProvider, target: target) attachments.append(attachment) } return attachments } private static func loadImage(from provider: NSItemProvider, target: WildReportPickerTarget) async throws -> WildReportAttachment { try await withCheckedThrowingContinuation { continuation in guard provider.canLoadObject(ofClass: UIImage.self) else { continuation.resume(throwing: OSSUploadError.unsupportedFileType) return } provider.loadObject(ofClass: UIImage.self) { object, error in if let error { continuation.resume(throwing: error) return } guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { continuation.resume(throwing: OSSUploadError.emptyFile) return } do { let fileName = "report_image_\(UUID().uuidString).jpg" let url = try write(data: data, fileName: fileName) continuation.resume(returning: WildReportAttachment( kind: target.attachmentKind, title: target.titlePrefix, localFileURL: url, fileName: fileName )) } catch { continuation.resume(throwing: error) } } } } private static func loadVideo(from provider: NSItemProvider, target: WildReportPickerTarget) async throws -> WildReportAttachment { try await withCheckedThrowingContinuation { continuation in provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in if let error { continuation.resume(throwing: error) return } guard let url else { continuation.resume(throwing: OSSUploadError.emptyFile) return } do { let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension let fileName = "report_video_\(UUID().uuidString).\(ext)" let targetURL = try temporaryFileURL(fileName: fileName) if FileManager.default.fileExists(atPath: targetURL.path) { try FileManager.default.removeItem(at: targetURL) } try FileManager.default.copyItem(at: url, to: targetURL) continuation.resume(returning: WildReportAttachment( kind: target.attachmentKind, title: target.titlePrefix, localFileURL: targetURL, fileName: fileName )) } catch { continuation.resume(throwing: error) } } } } private static func write(data: Data, fileName: String) throws -> URL { let url = try temporaryFileURL(fileName: fileName) try data.write(to: url, options: .atomic) return url } private static func temporaryFileURL(fileName: String) throws -> URL { let directory = FileManager.default.temporaryDirectory.appendingPathComponent("wild_report_media", isDirectory: true) if !FileManager.default.fileExists(atPath: directory.path) { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } return directory.appendingPathComponent(fileName) } } /// 提交页步骤标题,复刻参考页左侧蓝色竖条与选填标识。 private final class WildReportSectionTitleView: UIView { init(index: Int, title: String, optional: Bool) { super.init(frame: .zero) let bar = UIView() bar.backgroundColor = AppColor.primary bar.layer.cornerRadius = 2 let label = UILabel() label.font = .systemFont(ofSize: 17, weight: .bold) label.textColor = AppColor.textPrimary label.text = "\(index). \(title)" let optionalLabel = UILabel() optionalLabel.font = .systemFont(ofSize: 14) optionalLabel.textColor = AppColor.textSecondary optionalLabel.text = "(选填)" optionalLabel.isHidden = !optional addSubview(bar) addSubview(label) addSubview(optionalLabel) bar.snp.makeConstraints { make in make.leading.equalToSuperview() make.centerY.equalToSuperview() make.width.equalTo(4) make.height.equalTo(20) } label.snp.makeConstraints { make in make.leading.equalTo(bar.snp.trailing).offset(8) make.top.bottom.equalToSuperview() } optionalLabel.snp.makeConstraints { make in make.leading.equalTo(label.snp.trailing) make.centerY.equalTo(label) make.trailing.lessThanOrEqualToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 提交页举报类型三列选择按钮。 private final class WildReportTypeOptionButton: UIControl { private let titleLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) layer.cornerRadius = 12 layer.borderWidth = 1 titleLabel.font = .systemFont(ofSize: 15, weight: .bold) titleLabel.textAlignment = .center titleLabel.adjustsFontSizeToFitWidth = true titleLabel.minimumScaleFactor = 0.8 titleLabel.numberOfLines = 2 titleLabel.lineBreakMode = .byTruncatingTail titleLabel.isUserInteractionEnabled = false addSubview(titleLabel) titleLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.trailing.equalToSuperview().inset(6) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(type: WildReportType, isSelected: Bool) { titleLabel.text = type.label backgroundColor = isSelected ? AppColor.primary : UIColor(hex: 0xF6FAFF) layer.borderColor = (isSelected ? AppColor.primary : UIColor(hex: 0xC8DDFF)).cgColor titleLabel.textColor = isSelected ? .white : AppColor.textPrimary } } /// 提交页附件缩略 tile,优先展示本地图片或视频封面。 private final class WildReportAttachmentTileView: UIControl { private let gradientLayer = CAGradientLayer() private let previewAction: () -> Void private let removeAction: () -> Void init( attachment: WildReportAttachment, onPreview: @escaping () -> Void, onRemove: @escaping () -> Void ) { self.previewAction = onPreview self.removeAction = onRemove super.init(frame: .zero) layer.cornerRadius = 10 clipsToBounds = false gradientLayer.cornerRadius = 10 let colors: [UIColor] = attachment.kind == .video ? [UIColor(hex: 0xB7D8FF), UIColor(hex: 0x4C8FEF)] : [UIColor(hex: 0xCFE6FF), UIColor(hex: 0x7EB4FF)] gradientLayer.colors = colors.map(\.cgColor) gradientLayer.startPoint = CGPoint(x: 0, y: 0) gradientLayer.endPoint = CGPoint(x: 1, y: 1) layer.insertSublayer(gradientLayer, at: 0) let thumbnailView = UIImageView() thumbnailView.image = WildReportAttachmentThumbnailFactory.thumbnail(for: attachment) thumbnailView.contentMode = .scaleAspectFill thumbnailView.clipsToBounds = true thumbnailView.layer.cornerRadius = 10 thumbnailView.isUserInteractionEnabled = false thumbnailView.isHidden = thumbnailView.image == nil addSubview(thumbnailView) thumbnailView.snp.makeConstraints { make in make.edges.equalToSuperview() } let icon = UIImageView(image: UIImage(systemName: attachment.kind == .video ? "play.circle.fill" : "mountain.2.fill")) icon.tintColor = UIColor.white.withAlphaComponent(0.95) icon.contentMode = .scaleAspectFit icon.isHidden = attachment.kind != .video && thumbnailView.image != nil addSubview(icon) icon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(attachment.kind == .video ? 30 : 26) } let removeButton = UIButton(type: .system) removeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) removeButton.setPreferredSymbolConfiguration(UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold), forImageIn: .normal) removeButton.tintColor = UIColor(hex: 0x8D93A1) removeButton.backgroundColor = .white removeButton.layer.cornerRadius = 14 removeButton.layer.shadowColor = UIColor.black.cgColor removeButton.layer.shadowOpacity = 0.10 removeButton.layer.shadowRadius = 4 removeButton.layer.shadowOffset = CGSize(width: 0, height: 2) removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside) addSubview(removeButton) removeButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(-2) make.size.equalTo(34) } addTarget(self, action: #selector(previewTapped), for: .touchUpInside) snp.makeConstraints { make in make.size.equalTo(58) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = bounds } @objc private func previewTapped() { previewAction() } @objc private func removeTapped() { removeAction() } } /// 提交页附件封面生成器,负责从本地图片或视频文件提取缩略图。 private enum WildReportAttachmentThumbnailFactory { static func thumbnail(for attachment: WildReportAttachment) -> UIImage? { guard let url = attachment.localFileURL else { return nil } switch attachment.kind { case .image, .payment: return UIImage(contentsOfFile: url.path) case .video: return videoThumbnail(url: url) } } private static func videoThumbnail(url: URL) -> UIImage? { let asset = AVURLAsset(url: url) let generator = AVAssetImageGenerator(asset: asset) generator.appliesPreferredTrackTransform = true generator.maximumSize = CGSize(width: 160, height: 160) do { let cgImage = try generator.copyCGImage(at: CMTime(seconds: 0.1, preferredTimescale: 600), actualTime: nil) return UIImage(cgImage: cgImage) } catch { return nil } } } /// 提交页添加附件 tile,白底虚线加号。 private final class WildReportAddAttachmentTileView: UIControl { private let tapAction: () -> Void private let borderLayer = CAShapeLayer() init(action: @escaping () -> Void) { self.tapAction = action super.init(frame: .zero) backgroundColor = .white layer.cornerRadius = 10 borderLayer.strokeColor = UIColor(hex: 0xCDD5E1).cgColor borderLayer.fillColor = UIColor.clear.cgColor borderLayer.lineWidth = 1 borderLayer.lineDashPattern = [5, 4] layer.addSublayer(borderLayer) let icon = UIImageView(image: UIImage(systemName: "plus")) icon.tintColor = UIColor(hex: 0x8D93A1) icon.contentMode = .scaleAspectFit addSubview(icon) icon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(30) } addTarget(self, action: #selector(tapped), for: .touchUpInside) snp.makeConstraints { make in make.size.equalTo(58) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() borderLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath } @objc private func tapped() { tapAction() } } /// 举报成功页面,展示编号、上传摘要和处理进度。 final class WildPhotographerReportSuccessViewController: BaseViewController { private let viewModel: WildPhotographerReportSuccessViewModel private let homeViewModel: WildPhotographerReportHomeViewModel private let scrollView = UIScrollView() private let stack = UIStackView() init(result: WildReportSubmitResult, homeViewModel: WildPhotographerReportHomeViewModel) { self.viewModel = WildPhotographerReportSuccessViewModel(result: result) self.homeViewModel = homeViewModel super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { title = "举报成功" } override func setupUI() { view.backgroundColor = UIColor(hex: 0xF8FAFF) scrollView.showsVerticalScrollIndicator = false stack.axis = .vertical stack.spacing = 12 view.addSubview(scrollView) scrollView.addSubview(stack) stack.addArrangedSubview(WildReportSuccessHeroView()) stack.addArrangedSubview(WildReportSuccessProgressView(steps: viewModel.progressSteps)) let detailCard = WildReportSuccessDetailCardView( reportID: viewModel.result.record.id, reportType: viewModel.result.record.reportType.label, location: viewModel.result.record.location, uploadSummary: viewModel.uploadSummary ) detailCard.onCopyReportID = { [weak self] in guard let self else { return } _ = viewModel.copyReportID() showToast("举报编号已复制") } stack.addArrangedSubview(detailCard) let detailButton = makeActionButton(title: "查看处理进度", style: .primary) detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside) let homeButton = makeActionButton(title: "返回首页", style: .secondary) homeButton.addTarget(self, action: #selector(homeTapped), for: .touchUpInside) stack.addArrangedSubview(detailButton) stack.addArrangedSubview(homeButton) } override func setupConstraints() { scrollView.snp.makeConstraints { make in make.edges.equalTo(view.safeAreaLayoutGuide) } stack.snp.makeConstraints { make in make.top.equalToSuperview().offset(14) make.leading.trailing.equalTo(scrollView.frameLayoutGuide).inset(18) make.bottom.equalToSuperview().inset(28) make.width.equalTo(scrollView.frameLayoutGuide).offset(-36) } } private func makeActionButton(title: String, style: WildReportSuccessActionButtonStyle) -> UIButton { let button = UIButton(type: .system) button.titleLabel?.font = .systemFont(ofSize: 20, weight: .bold) button.setTitle(title, for: .normal) button.layer.cornerRadius = 14 button.layer.borderWidth = style == .secondary ? 1.2 : 0 button.layer.borderColor = AppColor.primary.cgColor button.backgroundColor = style == .primary ? AppColor.primary : .white button.setTitleColor(style == .primary ? .white : AppColor.primary, for: .normal) button.snp.makeConstraints { make in make.height.equalTo(54) } return button } @objc private func detailTapped() { navigationController?.pushViewController( WildPhotographerReportDetailViewController(record: viewModel.result.record, homeViewModel: homeViewModel), animated: true ) } @objc private func homeTapped() { navigationController?.popToViewController( navigationController?.viewControllers.first(where: { $0 is WildPhotographerReportHomeViewController }) ?? self, animated: true ) } } /// 举报成功页底部操作按钮样式。 private enum WildReportSuccessActionButtonStyle { case primary case secondary } /// 举报成功页顶部渐变成功反馈区。 private final class WildReportSuccessHeroView: UIView { private let gradientLayer = CAGradientLayer() private let stack = UIStackView() override init(frame: CGRect) { super.init(frame: frame) setupUI() setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = bounds gradientLayer.cornerRadius = 18 } private func setupUI() { layer.cornerRadius = 18 layer.borderWidth = 1 layer.borderColor = UIColor(hex: 0xBFD7FF).cgColor layer.masksToBounds = true gradientLayer.colors = [ UIColor.white.cgColor, UIColor(hex: 0xEEF6FF).cgColor ] gradientLayer.startPoint = CGPoint(x: 0, y: 0) gradientLayer.endPoint = CGPoint(x: 1, y: 1) layer.insertSublayer(gradientLayer, at: 0) stack.axis = .vertical stack.alignment = .center stack.spacing = 14 let iconView = WildReportSuccessCheckmarkView() let titleLabel = WildReportUI.label("举报已提交", font: .systemFont(ofSize: 24, weight: .heavy), color: AppColor.textPrimary) let descriptionLabel = WildReportUI.label( "景区公安已收到线索,将尽快前往现场核实处理", font: .systemFont(ofSize: 14), color: AppColor.textSecondary, lines: 0 ) descriptionLabel.textAlignment = .center addSubview(stack) stack.addArrangedSubview(iconView) stack.addArrangedSubview(titleLabel) stack.addArrangedSubview(descriptionLabel) iconView.snp.makeConstraints { make in make.size.equalTo(78) } descriptionLabel.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(16) } } private func setupConstraints() { stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 24, left: 16, bottom: 24, right: 16)) } } } /// 举报成功页圆形对勾图标。 private final class WildReportSuccessCheckmarkView: UIView { private let outerCircle = UIView() private let innerCircle = UIView() private let iconView = UIImageView(image: UIImage(systemName: "checkmark")) override init(frame: CGRect) { super.init(frame: frame) setupUI() setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { outerCircle.backgroundColor = AppColor.primary.withAlphaComponent(0.12) outerCircle.layer.cornerRadius = 39 innerCircle.backgroundColor = AppColor.primary innerCircle.layer.cornerRadius = 29 iconView.tintColor = .white iconView.contentMode = .scaleAspectFit iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 28, weight: .bold) addSubview(outerCircle) addSubview(innerCircle) addSubview(iconView) } private func setupConstraints() { outerCircle.snp.makeConstraints { make in make.edges.equalToSuperview() } innerCircle.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(58) } iconView.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(28) } } } /// 举报成功页横向处理进度卡片。 private final class WildReportSuccessProgressView: UIView { private let stack = UIStackView() private let steps: [String] init(steps: [String]) { self.steps = steps super.init(frame: .zero) setupUI() setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { backgroundColor = .white layer.cornerRadius = 16 layer.borderWidth = 1 layer.borderColor = AppColor.border.cgColor stack.axis = .horizontal stack.alignment = .top stack.spacing = 0 addSubview(stack) for (index, step) in steps.enumerated() { let item = WildReportSuccessProgressItemView(title: step, isCompleted: index == 0) stack.addArrangedSubview(item) item.snp.makeConstraints { make in make.width.equalTo(74) } if index < steps.count - 1 { let connector = WildReportSuccessProgressConnectorView(isCompleted: index == 0) stack.addArrangedSubview(connector) } } } private func setupConstraints() { stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 12, bottom: 16, right: 12)) } } } /// 举报成功页单个进度节点。 private final class WildReportSuccessProgressItemView: UIView { private let nodeView: WildReportSuccessProgressNodeView private let titleLabel = UILabel() init(title: String, isCompleted: Bool) { self.nodeView = WildReportSuccessProgressNodeView(isCompleted: isCompleted) super.init(frame: .zero) setupUI(title: title, isCompleted: isCompleted) setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI(title: String, isCompleted: Bool) { titleLabel.text = title titleLabel.font = .systemFont(ofSize: 11, weight: isCompleted ? .semibold : .regular) titleLabel.textColor = isCompleted ? AppColor.primary : UIColor(hex: 0x9CA3AF) titleLabel.textAlignment = .center titleLabel.numberOfLines = 2 addSubview(nodeView) addSubview(titleLabel) } private func setupConstraints() { nodeView.snp.makeConstraints { make in make.top.centerX.equalToSuperview() make.size.equalTo(24) } titleLabel.snp.makeConstraints { make in make.top.equalTo(nodeView.snp.bottom).offset(8) make.leading.trailing.bottom.equalToSuperview() } } } /// 举报成功页进度节点圆点。 private final class WildReportSuccessProgressNodeView: UIView { private let innerCircle = UIView() private let checkmarkView = UIImageView(image: UIImage(systemName: "checkmark")) private let isCompleted: Bool init(isCompleted: Bool) { self.isCompleted = isCompleted super.init(frame: .zero) setupUI() setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { layer.cornerRadius = 12 layer.borderWidth = 2 layer.borderColor = (isCompleted ? AppColor.primary : UIColor(hex: 0xD1D5DB)).cgColor innerCircle.backgroundColor = isCompleted ? AppColor.primary : .clear innerCircle.layer.cornerRadius = 10 checkmarkView.tintColor = .white checkmarkView.contentMode = .scaleAspectFit checkmarkView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 10, weight: .bold) checkmarkView.isHidden = !isCompleted addSubview(innerCircle) addSubview(checkmarkView) } private func setupConstraints() { innerCircle.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(20) } checkmarkView.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(10) } } } /// 举报成功页进度节点连接线。 private final class WildReportSuccessProgressConnectorView: UIView { private let isCompleted: Bool private let solidLine = UIView() private let dotsStack = UIStackView() init(isCompleted: Bool) { self.isCompleted = isCompleted super.init(frame: .zero) setupUI() setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { if isCompleted { solidLine.backgroundColor = AppColor.primary addSubview(solidLine) } else { dotsStack.axis = .horizontal dotsStack.alignment = .center dotsStack.distribution = .equalSpacing dotsStack.spacing = 3 addSubview(dotsStack) for _ in 0..<5 { let dot = UIView() dot.backgroundColor = UIColor(hex: 0xD1D5DB) dot.layer.cornerRadius = 1.5 dot.snp.makeConstraints { make in make.size.equalTo(3) } dotsStack.addArrangedSubview(dot) } } } private func setupConstraints() { snp.makeConstraints { make in make.height.equalTo(24) } if isCompleted { solidLine.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() make.centerY.equalToSuperview() make.height.equalTo(2) } } else { dotsStack.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.trailing.equalToSuperview() } } } } /// 举报成功页详情信息卡。 private final class WildReportSuccessDetailCardView: UIView { var onCopyReportID: (() -> Void)? private let stack = UIStackView() init(reportID: String, reportType: String, location: String, uploadSummary: String) { super.init(frame: .zero) setupUI(reportID: reportID, reportType: reportType, location: location, uploadSummary: uploadSummary) setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI(reportID: String, reportType: String, location: String, uploadSummary: String) { backgroundColor = .white layer.cornerRadius = 16 layer.borderWidth = 1 layer.borderColor = AppColor.border.cgColor stack.axis = .vertical stack.spacing = 0 addSubview(stack) appendRow(icon: "doc.text.fill", title: "举报编号", value: reportID, valueColor: AppColor.primary, showsCopy: true) appendDivider() appendRow(icon: "tag.fill", title: "举报类型", value: reportType) appendDivider() appendRow(icon: "mappin.circle.fill", title: "举报位置", value: location) appendDivider() appendRow(icon: "photo.on.rectangle.angled", title: "已上传", value: uploadSummary) } private func setupConstraints() { stack.snp.makeConstraints { make in make.edges.equalToSuperview() } } private func appendRow(icon: String, title: String, value: String, valueColor: UIColor = AppColor.textPrimary, showsCopy: Bool = false) { let row = WildReportSuccessDetailRowView( icon: icon, title: title, value: value, valueColor: valueColor, showsCopy: showsCopy ) row.onCopy = { [weak self] in self?.onCopyReportID?() } stack.addArrangedSubview(row) } private func appendDivider() { let container = UIView() let divider = UIView() divider.backgroundColor = AppColor.border container.addSubview(divider) divider.snp.makeConstraints { make in make.top.bottom.trailing.equalToSuperview() make.leading.equalToSuperview().offset(56) make.height.equalTo(1) } stack.addArrangedSubview(container) } } /// 举报成功页详情信息行。 private final class WildReportSuccessDetailRowView: UIView { var onCopy: (() -> Void)? private let iconContainer = UIView() private let iconView = UIImageView() private let titleLabel = UILabel() private let valueLabel = UILabel() private let copyButton = UIButton(type: .system) private let showsCopy: Bool init(icon: String, title: String, value: String, valueColor: UIColor, showsCopy: Bool) { self.showsCopy = showsCopy super.init(frame: .zero) setupUI(icon: icon, title: title, value: value, valueColor: valueColor) setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI(icon: String, title: String, value: String, valueColor: UIColor) { iconContainer.backgroundColor = AppColor.primaryLight iconContainer.layer.cornerRadius = 10 iconView.image = UIImage(systemName: icon) iconView.tintColor = AppColor.primary iconView.contentMode = .scaleAspectFit iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .semibold) titleLabel.text = title titleLabel.font = .systemFont(ofSize: 13) titleLabel.textColor = AppColor.textSecondary valueLabel.text = value valueLabel.font = .systemFont(ofSize: 16, weight: .semibold) valueLabel.textColor = valueColor valueLabel.numberOfLines = 2 valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) addSubview(iconContainer) iconContainer.addSubview(iconView) addSubview(titleLabel) addSubview(valueLabel) guard showsCopy else { return } copyButton.setImage(UIImage(systemName: "doc.on.doc"), for: .normal) copyButton.tintColor = AppColor.primary copyButton.backgroundColor = AppColor.primaryLight copyButton.layer.cornerRadius = 17 copyButton.accessibilityLabel = "复制举报编号" copyButton.addTarget(self, action: #selector(copyTapped), for: .touchUpInside) addSubview(copyButton) } private func setupConstraints() { iconContainer.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.centerY.equalToSuperview() make.size.equalTo(42) make.top.greaterThanOrEqualToSuperview().offset(14) make.bottom.lessThanOrEqualToSuperview().inset(14) } iconView.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(22) } titleLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(14) make.leading.equalTo(iconContainer.snp.trailing).offset(14) make.trailing.lessThanOrEqualToSuperview().inset(16) } valueLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(4) make.leading.equalTo(titleLabel) make.bottom.equalToSuperview().inset(14) if showsCopy { make.trailing.lessThanOrEqualTo(copyButton.snp.leading).offset(-8) } else { make.trailing.equalToSuperview().inset(16) } } guard showsCopy else { return } copyButton.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(16) make.centerY.equalToSuperview() make.size.equalTo(34) } } @objc private func copyTapped() { onCopy?() } }