重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
1381 lines
51 KiB
Swift
1381 lines
51 KiB
Swift
//
|
|
// WildPhotographerReportSubmitViewController.swift
|
|
// suixinkan
|
|
//
|
|
|
|
import PhotosUI
|
|
import SnapKit
|
|
import UIKit
|
|
|
|
/// 提交举报页面,提供类型、证据、说明、定位和联系方式表单。
|
|
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 descriptionPlaceholderLabel = UILabel()
|
|
private let descriptionCountLabel = UILabel()
|
|
private let contactField = UITextField()
|
|
private let submitButton = UIButton(type: .system)
|
|
private var pickerTarget: WildReportPickerTarget = .image
|
|
private weak var activeInputView: UIView?
|
|
private var isShowingSubmitLoading = false
|
|
|
|
init(
|
|
homeViewModel: WildPhotographerReportHomeViewModel,
|
|
locationProvider: any LocationProviding = LocationProvider.shared,
|
|
api: (any WildPhotographerReportServing)? = nil,
|
|
uploader: (any WildReportAttachmentUploading)? = nil
|
|
) {
|
|
self.homeViewModel = homeViewModel
|
|
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
|
self.uploader = uploader ?? NetworkServices.shared.ossUploadService
|
|
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
|
|
|
|
descriptionPlaceholderLabel.text = "请描述摄影师的位置、特征、行为..."
|
|
descriptionPlaceholderLabel.font = .systemFont(ofSize: 15)
|
|
descriptionPlaceholderLabel.textColor = UIColor(hex: 0x9CA3AF)
|
|
|
|
descriptionCountLabel.font = .systemFont(ofSize: 14)
|
|
descriptionCountLabel.textColor = UIColor(hex: 0x6B7280)
|
|
descriptionCountLabel.textAlignment = .right
|
|
|
|
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
|
|
guard let self else { return }
|
|
self.updateSubmitLoading()
|
|
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("提交举报", 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
|
|
if types.isEmpty, !viewModel.isLoadingReportTypes {
|
|
let empty = WildReportUI.label("暂无可选举报类型,请稍后重试", font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
|
|
card.stack.addArrangedSubview(empty)
|
|
return card
|
|
}
|
|
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: viewModel.selectedReportType == type)
|
|
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
|
|
updateDescriptionInputState()
|
|
let inputContainer = UIView()
|
|
inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE)
|
|
inputContainer.layer.cornerRadius = 12
|
|
inputContainer.layer.borderWidth = 1
|
|
inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor
|
|
|
|
inputContainer.addSubview(descriptionView)
|
|
inputContainer.addSubview(descriptionPlaceholderLabel)
|
|
inputContainer.addSubview(descriptionCountLabel)
|
|
descriptionView.snp.makeConstraints { make in
|
|
make.edges.equalToSuperview()
|
|
make.height.equalTo(132)
|
|
}
|
|
let textOriginX = descriptionView.textContainerInset.left
|
|
+ descriptionView.textContainer.lineFragmentPadding
|
|
descriptionPlaceholderLabel.snp.makeConstraints { make in
|
|
make.top.equalToSuperview().offset(descriptionView.textContainerInset.top)
|
|
make.leading.equalToSuperview().offset(textOriginX)
|
|
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
|
}
|
|
descriptionCountLabel.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
|
|
textStack.addArrangedSubview(title)
|
|
|
|
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 updateDescriptionInputState() {
|
|
descriptionPlaceholderLabel.isHidden = !viewModel.description.isEmpty
|
|
descriptionCountLabel.text = "\(viewModel.description.count)/500"
|
|
}
|
|
|
|
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..<addCount {
|
|
let addTile = WildReportAddAttachmentTileView { [weak self] in
|
|
self?.presentMediaPicker(for: WildReportPickerTarget(kind: kind))
|
|
}
|
|
tiles.addArrangedSubview(addTile)
|
|
}
|
|
scroll.snp.makeConstraints { make in
|
|
make.height.equalTo(62)
|
|
}
|
|
block.addArrangedSubview(scroll)
|
|
return block
|
|
}
|
|
|
|
private func makeDivider() -> 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 item = MediaPreviewItem(wildReportAttachment: attachment) else {
|
|
showToast("当前附件暂无本地预览")
|
|
return
|
|
}
|
|
presentMediaPreview(items: [item], startIndex: 0)
|
|
}
|
|
|
|
@objc private func refreshLocationTapped() { viewModel.refreshCurrentLocation() }
|
|
@objc private func submitTapped() {
|
|
Task {
|
|
await viewModel.submitReport(
|
|
api: api,
|
|
uploader: uploader,
|
|
scenicId: AppStore.shared.session.currentScenicId,
|
|
scenicName: AppStore.shared.session.currentScenicName
|
|
)
|
|
}
|
|
}
|
|
@objc private func contactChanged() {
|
|
viewModel.updateContact(contactField.text ?? "")
|
|
guard contactField.markedTextRange == nil,
|
|
contactField.text != viewModel.contact else { return }
|
|
contactField.text = viewModel.contact
|
|
}
|
|
@objc private func dismissKeyboard() { view.endEditing(true) }
|
|
|
|
@MainActor
|
|
private func updateSubmitLoading() {
|
|
if viewModel.isSubmitting {
|
|
guard !isShowingSubmitLoading else { return }
|
|
isShowingSubmitLoading = true
|
|
showLoading()
|
|
} else if isShowingSubmitLoading {
|
|
isShowingSubmitLoading = false
|
|
hideLoading()
|
|
}
|
|
}
|
|
|
|
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: 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)
|
|
if textView.markedTextRange == nil, textView.text != viewModel.description {
|
|
textView.text = viewModel.description
|
|
}
|
|
updateDescriptionInputState()
|
|
}
|
|
|
|
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 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
|
|
}
|
|
}
|
|
|
|
/// 举报成功页面,展示编号、上传摘要和处理进度。
|
|
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 contentView = UIView()
|
|
private let stack = UIStackView()
|
|
private let steps: [String]
|
|
private var itemViews: [WildReportSuccessProgressItemView] = []
|
|
|
|
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.distribution = .fillEqually
|
|
stack.spacing = 0
|
|
|
|
addSubview(contentView)
|
|
contentView.addSubview(stack)
|
|
for (index, step) in steps.enumerated() {
|
|
let item = WildReportSuccessProgressItemView(title: step, isCompleted: index == 0)
|
|
itemViews.append(item)
|
|
stack.addArrangedSubview(item)
|
|
}
|
|
|
|
for index in steps.indices.dropLast() {
|
|
if index < steps.count - 1 {
|
|
let connector = WildReportSuccessProgressConnectorView(isCompleted: index == 0)
|
|
contentView.insertSubview(connector, belowSubview: stack)
|
|
connector.snp.makeConstraints { make in
|
|
make.leading.equalTo(itemViews[index].connectorAnchorView.snp.centerX).offset(12)
|
|
make.trailing.equalTo(itemViews[index + 1].connectorAnchorView.snp.centerX).offset(-12)
|
|
make.centerY.equalTo(itemViews[index].connectorAnchorView)
|
|
make.height.equalTo(24)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setupConstraints() {
|
|
contentView.snp.makeConstraints { make in
|
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 12, bottom: 16, right: 12))
|
|
}
|
|
stack.snp.makeConstraints { make in
|
|
make.edges.equalToSuperview()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 举报成功页单个进度节点。
|
|
private final class WildReportSuccessProgressItemView: UIView {
|
|
private let nodeView: WildReportSuccessProgressNodeView
|
|
private let titleLabel = UILabel()
|
|
|
|
var connectorAnchorView: UIView { nodeView }
|
|
|
|
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?()
|
|
}
|
|
}
|