Files
suixinkan_uikit/suixinkan/UI/PunchPoint/PunchPointFormViewController.swift

669 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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(symbol: "plus")
private let zoomOutButton = PunchPointMapControlButton(symbol: "minus")
private let locateButton = PunchPointMapControlButton(symbol: "location.fill")
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(cardView.snp.top).offset(-16)
}
locateButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(zoomStack.snp.top).offset(-12)
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?.showToast(message) }
}
viewModel.onCoordinateChange = { [weak self] coordinate in
Task { @MainActor in
self?.mapView.updateMarker(coordinate: coordinate)
self?.mapView.setCenter(coordinate, zoomLevel: 17)
}
}
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)
}
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: 14)
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: 44))
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(44)
}
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: 14)
valueLabel.textColor = UIColor(hex: 0x999999)
locateButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
locateButton.tintColor = AppColor.textPrimary
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(44)
}
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: 14)
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(92)
}
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(systemName: "plus"))
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 = .white
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
addIcon.tintColor = AppColor.primary
deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
deleteButton.tintColor = .white
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(deleteButton)
addSubview(overlayLabel)
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
addIcon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(28)
}
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() {
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
layer.borderWidth = 1
imageView.image = nil
imageView.kf.cancelDownloadTask()
addIcon.isHidden = false
deleteButton.isHidden = true
overlayLabel.isHidden = true
}
func apply(item: PunchPointImageState) {
layer.borderWidth = 0
addIcon.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?()
}
}