// // PunchPointFormViewController.swift // suixinkan // import Kingfisher import PhotosUI import SnapKit import UIKit /// 打卡点新建与编辑页,对齐 Android `AddPunchPointScreen` / `EditPunchPointScreen`。 final class PunchPointFormViewController: BaseViewController { private let viewModel: PunchPointFormViewModel private let api: any PunchPointAPIProtocol private let uploader: any PunchPointImageUploading private let initialDetail: PunchPointDetail? private let mapView = PunchPointMapView() private let zoomStack = UIStackView() private let zoomInButton = PunchPointMapControlButton(assetName: "punch_point_zoom_in") private let zoomOutButton = PunchPointMapControlButton(assetName: "punch_point_zoom_out") private let locateButton = PunchPointMapControlButton(assetName: "punch_point_map_location") private let cardView = UIView() private let scrollView = UIScrollView() private let formStack = UIStackView() private let nameField = PunchPointTextFieldView(title: "*打卡点名称", placeholder: "请输入打卡点名称") private let coordinateField = PunchPointCoordinateFieldView(title: "*打卡点坐标", placeholder: "点击右侧按钮,定位打卡点坐标") private let addressField = PunchPointTextFieldView(title: "*打卡点地址", placeholder: "请输入打卡点地址") private let descriptionField = PunchPointTextViewFieldView(title: "打卡点描述", placeholder: "请输入内容 (50字以内)") private let imageGrid = PunchPointImageGridView() private let bottomBar = UIView() private let submitButton = UIButton(type: .system) private var progressAlert: UIAlertController? private var progressView: UIProgressView? private var progressLabel: UILabel? var onSubmitSuccess: (() -> Void)? /// 初始化打卡点表单页。 init( mode: PunchPointFormViewModel.Mode, initialDetail: PunchPointDetail? = nil, viewModel: PunchPointFormViewModel? = nil, api: (any PunchPointAPIProtocol)? = nil, uploader: (any PunchPointImageUploading)? = nil ) { self.viewModel = viewModel ?? PunchPointFormViewModel(mode: mode) self.api = api ?? NetworkServices.shared.punchPointAPI self.uploader = uploader ?? NetworkServices.shared.ossUploadService self.initialDetail = initialDetail super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { switch viewModel.mode { case .create: title = "新建打卡点" case .edit: title = "编辑打卡点" } } override func setupUI() { view.backgroundColor = AppColor.pageBackground zoomStack.axis = .vertical zoomStack.spacing = 0 zoomStack.addArrangedSubview(zoomInButton) zoomStack.addArrangedSubview(zoomOutButton) cardView.backgroundColor = .white cardView.layer.cornerRadius = 12 cardView.clipsToBounds = true scrollView.keyboardDismissMode = .interactive scrollView.showsVerticalScrollIndicator = false formStack.axis = .vertical formStack.spacing = 16 imageGrid.title = "*上传图片 (最多9张)" bottomBar.backgroundColor = .white submitButton.setTitle("提交审核", for: .normal) submitButton.setTitleColor(.white, for: .normal) submitButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) submitButton.backgroundColor = AppColor.primary submitButton.layer.cornerRadius = 8 view.addSubview(mapView) view.addSubview(zoomStack) view.addSubview(locateButton) view.addSubview(cardView) cardView.addSubview(scrollView) scrollView.addSubview(formStack) [nameField, coordinateField, addressField, descriptionField, imageGrid].forEach(formStack.addArrangedSubview) view.addSubview(bottomBar) bottomBar.addSubview(submitButton) } override func setupConstraints() { mapView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() make.height.equalToSuperview().multipliedBy(0.4) } zoomStack.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(16) make.bottom.equalTo(locateButton.snp.top).offset(-12) } locateButton.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(16) make.bottom.equalTo(cardView.snp.top).offset(-16) make.size.equalTo(40) } zoomInButton.snp.makeConstraints { make in make.size.equalTo(40) } zoomOutButton.snp.makeConstraints { make in make.size.equalTo(40) } bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() make.height.greaterThanOrEqualTo(84) } submitButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(52) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) } cardView.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(16) make.bottom.equalTo(bottomBar.snp.top).offset(-16) make.height.lessThanOrEqualToSuperview().multipliedBy(0.5) make.top.greaterThanOrEqualTo(mapView.snp.top).offset(40) } scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() } formStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 12, bottom: 12, right: 12)) make.width.equalTo(scrollView.snp.width).offset(-24) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.handleMessage(message) } } viewModel.onCoordinateChange = { [weak self] coordinate in Task { @MainActor in self?.applyMapCoordinate(coordinate) } } viewModel.onSubmitSuccess = { [weak self] in Task { @MainActor in self?.onSubmitSuccess?() self?.navigationController?.popViewController(animated: true) } } nameField.onTextChange = { [weak self] text in self?.viewModel.updateName(text) } addressField.onTextChange = { [weak self] text in self?.viewModel.updateAddress(text) } descriptionField.onTextChange = { [weak self] text in self?.viewModel.updateDescription(text) } coordinateField.onLocate = { [weak self] in guard let self else { return } Task { await self.viewModel.locateToCurrent() } } imageGrid.onAdd = { [weak self] in self?.presentImagePicker() } imageGrid.onDelete = { [weak self] index in self?.viewModel.deleteImage(at: index) } imageGrid.onRetry = { [weak self] index in guard let self else { return } Task { await self.viewModel.retryUpload(at: index, uploader: self.uploader) } } mapView.onMapTap = { [weak self] coordinate in guard let self else { return } Task { await self.viewModel.selectCoordinate(coordinate) } } zoomInButton.addTarget(self, action: #selector(zoomInTapped), for: .touchUpInside) zoomOutButton.addTarget(self, action: #selector(zoomOutTapped), for: .touchUpInside) locateButton.addTarget(self, action: #selector(locateTapped), for: .touchUpInside) submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) } override func viewDidLoad() { super.viewDidLoad() if let initialDetail { viewModel.initialize(with: initialDetail) } else { Task { await viewModel.autoLocation() } } } @MainActor private func applyViewModel() { if !nameField.isEditing { nameField.text = viewModel.name } if !addressField.isEditing { addressField.text = viewModel.address } if !descriptionField.isEditing { descriptionField.text = viewModel.description } coordinateField.text = viewModel.coordinatesText imageGrid.apply(images: viewModel.images) submitButton.isEnabled = !viewModel.isSubmitting submitButton.alpha = submitButton.isEnabled ? 1 : 0.65 submitButton.setTitle(viewModel.isSubmitting ? "提交中..." : "提交审核", for: .normal) updateUploadDialog(viewModel.uploadDialogState) } private func presentImagePicker() { let remaining = max(0, 9 - viewModel.images.count) guard remaining > 0 else { showToast("最多只能选择9张图片") return } var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.filter = .images configuration.selectionLimit = remaining let picker = PHPickerViewController(configuration: configuration) picker.delegate = self present(picker, animated: true) } private func makeImageState(image: UIImage, fileName: String) -> PunchPointImageState? { guard let data = image.jpegData(compressionQuality: 0.9) else { return nil } return PunchPointImageState(data: data, fileName: fileName) } @MainActor private func handleMessage(_ message: String) { guard message.contains("定位权限未授予") else { showToast(message) return } let alert = UIAlertController(title: "无法定位", message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "去设置", style: .default) { _ in guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) }) present(alert, animated: true) } @MainActor private func applyMapCoordinate(_ coordinate: CLLocationCoordinate2D) { if let initialDetail, let original = initialDetail.coordinate { mapView.updatePunchPointMarker( coordinate: original, imageURL: initialDetail.guideImages.first ) let isOriginal = abs(original.latitude - coordinate.latitude) < 0.000_001 && abs(original.longitude - coordinate.longitude) < 0.000_001 mapView.updateSelectedMarker(coordinate: isOriginal ? nil : coordinate) mapView.fitAllMarkers() } else { mapView.updatePunchPointMarker(coordinate: nil) mapView.updateSelectedMarker(coordinate: coordinate) mapView.setCenter(coordinate, zoomLevel: 17) } } private func updateUploadDialog(_ state: PunchPointUploadDialogState?) { guard let state else { progressAlert?.dismiss(animated: true) progressAlert = nil progressView = nil progressLabel = nil return } if progressAlert == nil { let alert = UIAlertController(title: state.title, message: "\n\n", preferredStyle: .alert) let progress = UIProgressView(progressViewStyle: .default) progress.progressTintColor = AppColor.primary let label = UILabel() label.font = .systemFont(ofSize: 14) label.textColor = AppColor.textSecondary label.textAlignment = .center alert.view.addSubview(progress) alert.view.addSubview(label) progress.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(28) make.top.equalToSuperview().offset(88) } label.snp.makeConstraints { make in make.top.equalTo(progress.snp.bottom).offset(12) make.centerX.equalToSuperview() } progressAlert = alert progressView = progress progressLabel = label present(alert, animated: true) } progressAlert?.title = state.title progressView?.progress = Float(state.progress) / 100.0 progressLabel?.text = "\(state.progress)%" } @objc private func zoomInTapped() { mapView.zoomIn() } @objc private func zoomOutTapped() { mapView.zoomOut() } @objc private func locateTapped() { Task { await viewModel.locateToCurrent() } } @objc private func submitTapped() { Task { await viewModel.submit(api: api) } } } extension PunchPointFormViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard !results.isEmpty else { return } var loadedItems = [PunchPointImageState?](repeating: nil, count: results.count) let group = DispatchGroup() for (index, result) in results.enumerated() { group.enter() result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in defer { group.leave() } guard let self, let image = object as? UIImage else { return } let fileName = "punch_point_\(Int(Date().timeIntervalSince1970))_\(index).jpg" loadedItems[index] = self.makeImageState(image: image, fileName: fileName) } } group.notify(queue: .main) { [weak self] in guard let self else { return } Task { await self.viewModel.addLocalImages(loadedItems.compactMap { $0 }, uploader: self.uploader) } } } } /// 打卡点表单单行输入字段。 /// 打卡点表单单行输入字段。 private final class PunchPointTextFieldView: UIView { private let titleLabel = UILabel() private let textField = UITextField() var onTextChange: ((String) -> Void)? var isEditing: Bool { textField.isFirstResponder } var text: String { get { textField.text ?? "" } set { if textField.text != newValue { textField.text = newValue } } } init(title: String, placeholder: String) { super.init(frame: .zero) titleLabel.attributedText = Self.requiredTitle(title) textField.placeholder = placeholder textField.font = .systemFont(ofSize: 15) textField.textColor = AppColor.textPrimary textField.backgroundColor = .white textField.layer.cornerRadius = 8 textField.layer.borderColor = AppColor.border.cgColor textField.layer.borderWidth = 1 textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 46)) textField.leftViewMode = .always addSubview(titleLabel) addSubview(textField) titleLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() } textField.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(8) make.leading.trailing.bottom.equalToSuperview() make.height.equalTo(46) } textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } fileprivate static func requiredTitle(_ text: String) -> NSAttributedString { let mutable = NSMutableAttributedString(string: text, attributes: [ .font: UIFont.systemFont(ofSize: 14, weight: .medium), .foregroundColor: UIColor(hex: 0x2E3746), ]) if text.hasPrefix("*") { mutable.addAttribute(.foregroundColor, value: AppColor.danger, range: NSRange(location: 0, length: 1)) } return mutable } @objc private func textChanged() { onTextChange?(textField.text ?? "") } } /// 打卡点坐标只读字段。 /// 打卡点表单坐标选择字段。 private final class PunchPointCoordinateFieldView: UIView { private let titleLabel = UILabel() private let container = UIView() private let valueLabel = UILabel() private let locateButton = UIButton(type: .system) private let placeholder: String var onLocate: (() -> Void)? var text: String = "" { didSet { valueLabel.text = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? placeholder : text valueLabel.textColor = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? UIColor(hex: 0x999999) : AppColor.textPrimary } } init(title: String, placeholder: String) { self.placeholder = placeholder super.init(frame: .zero) titleLabel.attributedText = PunchPointTextFieldView.requiredTitle(title) container.layer.cornerRadius = 8 container.layer.borderWidth = 1 container.layer.borderColor = AppColor.border.cgColor valueLabel.text = placeholder valueLabel.font = .systemFont(ofSize: 15) valueLabel.textColor = UIColor(hex: 0x999999) locateButton.setImage(UIImage(named: "punch_point_location"), for: .normal) locateButton.imageView?.contentMode = .scaleAspectFit locateButton.accessibilityLabel = "定位打卡点坐标" addSubview(titleLabel) addSubview(container) container.addSubview(valueLabel) container.addSubview(locateButton) titleLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() } container.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(8) make.leading.trailing.bottom.equalToSuperview() make.height.equalTo(46) } valueLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.centerY.equalToSuperview() make.trailing.equalTo(locateButton.snp.leading).offset(-8) } locateButton.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(10) make.centerY.equalToSuperview() make.size.equalTo(26) } locateButton.addTarget(self, action: #selector(locateTapped), for: .touchUpInside) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func locateTapped() { onLocate?() } } /// 打卡点描述输入字段。 /// 打卡点表单多行描述字段。 private final class PunchPointTextViewFieldView: UIView, UITextViewDelegate { private let titleLabel = UILabel() private let textView = UITextView() private let placeholderLabel = UILabel() var onTextChange: ((String) -> Void)? var isEditing: Bool { textView.isFirstResponder } var text: String { get { textView.text ?? "" } set { if textView.text != newValue { textView.text = newValue placeholderLabel.isHidden = !newValue.isEmpty } } } init(title: String, placeholder: String) { super.init(frame: .zero) titleLabel.text = title titleLabel.font = .systemFont(ofSize: 14, weight: .medium) titleLabel.textColor = UIColor(hex: 0x333333) textView.font = .systemFont(ofSize: 15) textView.textColor = AppColor.textPrimary textView.layer.cornerRadius = 8 textView.layer.borderWidth = 1 textView.layer.borderColor = AppColor.border.cgColor textView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8) textView.delegate = self placeholderLabel.text = placeholder placeholderLabel.font = .systemFont(ofSize: 14) placeholderLabel.textColor = UIColor(hex: 0x999999) addSubview(titleLabel) addSubview(textView) textView.addSubview(placeholderLabel) titleLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() } textView.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(8) make.leading.trailing.bottom.equalToSuperview() make.height.equalTo(100) } placeholderLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(10) make.leading.equalToSuperview().offset(12) make.trailing.equalToSuperview().inset(12) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func textViewDidChange(_ textView: UITextView) { if textView.text.count > 50 { textView.text = String(textView.text.prefix(50)) } placeholderLabel.isHidden = !textView.text.isEmpty onTextChange?(textView.text) } } /// 打卡点图片上传网格。 /// 打卡点表单图片网格,负责新增、预览、删除与重试入口展示。 private final class PunchPointImageGridView: UIView { private let titleLabel = UILabel() private let stack = UIStackView() var onAdd: (() -> Void)? var onDelete: ((Int) -> Void)? var onRetry: ((Int) -> Void)? var title: String = "" { didSet { titleLabel.attributedText = PunchPointTextFieldView.requiredTitle(title) } } override init(frame: CGRect) { super.init(frame: frame) titleLabel.font = .systemFont(ofSize: 14, weight: .medium) stack.axis = .vertical stack.spacing = 12 addSubview(titleLabel) addSubview(stack) titleLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() } stack.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(8) make.leading.trailing.bottom.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(images: [PunchPointImageState]) { stack.arrangedSubviews.forEach { $0.removeFromSuperview() } let cells = images.map(Optional.some) + (images.count < 9 ? [nil] : []) let rows = Int(ceil(Double(cells.count) / 3.0)) guard rows > 0 else { return } for row in 0 ..< rows { let rowStack = UIStackView() rowStack.axis = .horizontal rowStack.spacing = 12 rowStack.distribution = .fillEqually stack.addArrangedSubview(rowStack) for column in 0 ..< 3 { let index = row * 3 + column if index < cells.count { rowStack.addArrangedSubview(makeCell(item: cells[index], index: index)) } else { rowStack.addArrangedSubview(UIView()) } } } } private func makeCell(item: PunchPointImageState?, index: Int) -> UIView { let cell = PunchPointImageThumbView() cell.snp.makeConstraints { make in make.height.equalTo(cell.snp.width).dividedBy(1.7) } if let item { cell.apply(item: item) cell.onDelete = { [weak self] in self?.onDelete?(index) } cell.onRetry = { [weak self] in self?.onRetry?(index) } } else { cell.applyAdd() cell.onAdd = { [weak self] in self?.onAdd?() } } return cell } } /// 打卡点图片上传缩略图。 /// 打卡点图片缩略图。 private final class PunchPointImageThumbView: UIControl { private let imageView = UIImageView() private let addIcon = UIImageView(image: UIImage(named: "punch_point_image_add")) private let addLabel = UILabel() private let deleteButton = UIButton(type: .system) private let overlayLabel = UILabel() var onAdd: (() -> Void)? var onDelete: (() -> Void)? var onRetry: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) layer.cornerRadius = 8 clipsToBounds = true backgroundColor = UIColor(hex: 0xF4F4F4) imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true addLabel.text = "点击上传" addLabel.font = .systemFont(ofSize: 12) addLabel.textColor = UIColor(hex: 0xB6BECA) addLabel.textAlignment = .center deleteButton.setImage(UIImage(named: "punch_point_image_delete"), for: .normal) deleteButton.accessibilityLabel = "删除图片" overlayLabel.font = .systemFont(ofSize: 14, weight: .medium) overlayLabel.textColor = .white overlayLabel.textAlignment = .center overlayLabel.backgroundColor = UIColor.black.withAlphaComponent(0.45) addSubview(imageView) addSubview(addIcon) addSubview(addLabel) addSubview(deleteButton) addSubview(overlayLabel) imageView.snp.makeConstraints { make in make.edges.equalToSuperview() } addIcon.snp.makeConstraints { make in make.centerX.equalToSuperview() make.centerY.equalToSuperview().offset(-8) make.size.equalTo(24) } addLabel.snp.makeConstraints { make in make.top.equalTo(addIcon.snp.bottom).offset(2) make.centerX.equalToSuperview() } deleteButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(4) make.size.equalTo(22) } overlayLabel.snp.makeConstraints { make in make.edges.equalToSuperview() } addTarget(self, action: #selector(tapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func applyAdd() { accessibilityLabel = "点击上传图片" layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor layer.borderWidth = 1 imageView.image = nil imageView.kf.cancelDownloadTask() addIcon.isHidden = false addLabel.isHidden = false deleteButton.isHidden = true overlayLabel.isHidden = true } func apply(item: PunchPointImageState) { accessibilityLabel = item.errorMessage == nil ? "打卡点图片" : "图片上传失败,点击重试" layer.borderWidth = 0 addIcon.isHidden = true addLabel.isHidden = true deleteButton.isHidden = item.isUploading if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty { imageView.kf.setImage(with: url) } else { imageView.image = UIImage(data: item.data) } if item.isUploading { overlayLabel.isHidden = false overlayLabel.text = "\(item.uploadProgress)%" } else if item.errorMessage != nil { overlayLabel.isHidden = false overlayLabel.text = "重新上传" overlayLabel.backgroundColor = AppColor.danger.withAlphaComponent(0.65) } else { overlayLabel.isHidden = true } } @objc private func tapped() { if !addIcon.isHidden { onAdd?() } else if !overlayLabel.isHidden, overlayLabel.text == "重新上传" { onRetry?() } } @objc private func deleteTapped() { onDelete?() } }