feat: update wallet punch point and report success

This commit is contained in:
2026-07-09 14:27:18 +08:00
parent 2970f1514b
commit 43e6133c21
34 changed files with 6671 additions and 71 deletions

View File

@ -0,0 +1,573 @@
//
// PunchPointDetailViewController.swift
// suixinkan
//
import Kingfisher
import Photos
import SnapKit
import UIKit
/// Android `PunchPointDetailScreen`
final class PunchPointDetailViewController: BaseViewController {
private let viewModel: PunchPointDetailViewModel
private let api: any PunchPointAPIProtocol
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 contentStack = UIStackView()
private let bottomBar = UIView()
private let editButton = UIButton(type: .system)
private let emptyContainer = UIStackView()
private let emptyLabel = UILabel()
private let retryButton = UIButton(type: .system)
var onChanged: (() -> Void)?
///
init(
punchPointId: Int64,
viewModel: PunchPointDetailViewModel? = nil,
api: (any PunchPointAPIProtocol)? = nil
) {
self.viewModel = viewModel ?? PunchPointDetailViewModel(punchPointId: punchPointId)
self.api = api ?? NetworkServices.shared.punchPointAPI
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 = 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.showsVerticalScrollIndicator = false
contentStack.axis = .vertical
contentStack.spacing = 12
bottomBar.backgroundColor = .white
editButton.setTitle("去编辑", for: .normal)
editButton.setTitleColor(.white, for: .normal)
editButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
editButton.backgroundColor = AppColor.primary
editButton.layer.cornerRadius = 12
emptyContainer.axis = .vertical
emptyContainer.alignment = .center
emptyContainer.spacing = 12
emptyLabel.text = "暂未获取到打卡点信息"
emptyLabel.textColor = AppColor.textSecondary
emptyLabel.font = .systemFont(ofSize: 15)
retryButton.setTitle("重新加载", for: .normal)
retryButton.setTitleColor(.white, for: .normal)
retryButton.backgroundColor = AppColor.primary
retryButton.layer.cornerRadius = 8
emptyContainer.addArrangedSubview(emptyLabel)
emptyContainer.addArrangedSubview(retryButton)
emptyContainer.isHidden = true
view.addSubview(mapView)
view.addSubview(zoomStack)
view.addSubview(locateButton)
view.addSubview(cardView)
cardView.addSubview(scrollView)
scrollView.addSubview(contentStack)
view.addSubview(bottomBar)
bottomBar.addSubview(editButton)
view.addSubview(emptyContainer)
}
override func setupConstraints() {
mapView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.height.equalToSuperview().multipliedBy(0.5)
}
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()
}
editButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-20)
}
cardView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top)
make.height.lessThanOrEqualToSuperview().multipliedBy(0.45)
make.top.greaterThanOrEqualTo(mapView.snp.top).offset(60)
}
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
contentStack.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)
}
emptyContainer.snp.makeConstraints { make in
make.center.equalToSuperview()
}
retryButton.snp.makeConstraints { make in
make.width.equalTo(118)
make.height.equalTo(40)
}
}
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) }
}
zoomInButton.addTarget(self, action: #selector(zoomInTapped), for: .touchUpInside)
zoomOutButton.addTarget(self, action: #selector(zoomOutTapped), for: .touchUpInside)
locateButton.addTarget(self, action: #selector(locateTapped), for: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.load(api: api) }
}
@MainActor
private func applyViewModel() {
viewModel.isLoading && viewModel.detail == nil ? showLoading() : hideLoading()
emptyContainer.isHidden = viewModel.detail != nil || viewModel.isLoading
guard let detail = viewModel.detail else {
cardView.isHidden = true
bottomBar.isHidden = true
return
}
cardView.isHidden = false
bottomBar.isHidden = false
if let region = detail.region {
let coordinate = CLLocationCoordinate2D(latitude: region.lat, longitude: region.lot)
mapView.updateMarker(coordinate: coordinate)
mapView.setCenter(coordinate, zoomLevel: 16)
}
rebuildContent(detail)
}
private func rebuildContent(_ detail: PunchPointDetail) {
contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点名称", value: detail.name))
let coordinateText = detail.region.map { "\($0.lat),\($0.lot)" } ?? "--"
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点坐标", value: coordinateText))
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点地址", value: detail.displayAddress.nonEmptyTrimmed ?? "--"))
if let description = detail.description?.nonEmptyTrimmed {
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "打卡点描述", value: description, multiline: true))
}
contentStack.addArrangedSubview(makeImageSection(detail.guideImages))
contentStack.addArrangedSubview(PunchPointRowField(title: "负责人", value: detail.creator?.nonEmptyTrimmed ?? "--"))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(PunchPointRowField(title: "负责人手机号", value: PunchPointDisplayFormatter.maskedPhone(detail.creatorPhone)))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(makeStatusRow(detail))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(PunchPointRowField(title: "审核人", value: detail.auditor?.nonEmptyTrimmed ?? "--"))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(PunchPointRowField(title: "审核时间", value: detail.auditTime?.nonEmptyTrimmed ?? "--"))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(PunchPointRowField(title: "审核备注", value: detail.auditRemark?.nonEmptyTrimmed ?? "--"))
contentStack.addArrangedSubview(makeQRSection(detail.mpQrcode))
}
private func makeImageSection(_ urls: [String]) -> UIView {
let section = UIStackView()
section.axis = .vertical
section.spacing = 8
let title = UILabel()
title.attributedText = requiredTitle("*上传图片 (最多9张)")
section.addArrangedSubview(title)
guard !urls.isEmpty else {
let empty = UILabel()
empty.text = "暂无图片"
empty.font = .systemFont(ofSize: 12)
empty.textColor = AppColor.textSecondary
section.addArrangedSubview(empty)
return section
}
let scroll = UIScrollView()
scroll.showsHorizontalScrollIndicator = false
let row = UIStackView()
row.axis = .horizontal
row.spacing = 12
scroll.addSubview(row)
row.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(90)
}
urls.enumerated().forEach { index, urlText in
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 10
imageView.isUserInteractionEnabled = true
imageView.tag = index
if let url = URL(string: urlText) {
imageView.kf.setImage(with: url)
}
imageView.snp.makeConstraints { make in
make.width.equalTo(120)
}
imageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imageTapped(_:))))
row.addArrangedSubview(imageView)
}
scroll.snp.makeConstraints { make in
make.height.equalTo(90)
}
section.addArrangedSubview(scroll)
return section
}
private func makeStatusRow(_ detail: PunchPointDetail) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.distribution = .equalSpacing
let label = UILabel()
label.text = "审核状态"
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0x2E3746)
let chip = PunchPointStatusChip()
chip.apply(text: detail.statusLabel.nonEmptyTrimmed ?? "--", status: detail.status)
row.addArrangedSubview(label)
row.addArrangedSubview(chip)
row.isLayoutMarginsRelativeArrangement = true
row.layoutMargins = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0)
return row
}
private func makeQRSection(_ qrCode: String?) -> UIView {
let section = UIStackView()
section.axis = .vertical
section.alignment = .center
section.spacing = 12
let title = UILabel()
title.text = "打卡点二维码"
title.font = .systemFont(ofSize: 14)
title.textColor = UIColor(hex: 0x2E3746)
title.textAlignment = .left
title.snp.makeConstraints { make in
make.width.equalTo(contentStack.snp.width)
}
section.addArrangedSubview(title)
guard let qrCode = qrCode?.nonEmptyTrimmed else {
let empty = UILabel()
empty.text = "暂无二维码"
empty.font = .systemFont(ofSize: 12)
empty.textColor = AppColor.textSecondary
section.addArrangedSubview(empty)
return section
}
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 16
if let url = URL(string: qrCode) {
imageView.kf.setImage(with: url)
}
imageView.snp.makeConstraints { make in
make.size.equalTo(180)
}
let download = UIButton(type: .system)
download.setTitle("下载二维码", for: .normal)
download.setTitleColor(AppColor.primary, for: .normal)
download.titleLabel?.font = .systemFont(ofSize: 14)
download.backgroundColor = AppColor.primaryLight
download.layer.cornerRadius = 8
download.snp.makeConstraints { make in
make.height.equalTo(38)
make.width.equalTo(128)
}
download.addAction(UIAction { [weak self] _ in
self?.downloadQRCode(qrCode)
}, for: .touchUpInside)
section.addArrangedSubview(imageView)
section.addArrangedSubview(download)
return section
}
private func downloadQRCode(_ urlText: String) {
guard let url = URL(string: urlText) else {
showToast("二维码地址无效")
return
}
Task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else { throw APIError.decodeFailed("二维码图片无效") }
try await PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.creationRequestForAsset(from: image)
}
await MainActor.run { self.showToast("已保存到相册") }
} catch {
await MainActor.run { self.showToast(error.localizedDescription.isEmpty ? "保存失败" : error.localizedDescription) }
}
}
}
private 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 imageTapped(_ gesture: UITapGestureRecognizer) {
guard let imageView = gesture.view, let urls = viewModel.detail?.guideImages else { return }
presentImagePreview(imageURLs: urls, startIndex: imageView.tag)
}
@objc private func zoomInTapped() {
mapView.zoomIn()
}
@objc private func zoomOutTapped() {
mapView.zoomOut()
}
@objc private func locateTapped() {
mapView.centerOnUserLocation()
}
@objc private func retryTapped() {
Task { await viewModel.load(api: api) }
}
@objc private func editTapped() {
guard let detail = viewModel.detail else { return }
let controller = PunchPointFormViewController(mode: .edit(id: detail.id), initialDetail: detail)
controller.onSubmitSuccess = { [weak self] in
guard let self else { return }
self.onChanged?()
Task { await self.viewModel.load(api: self.api) }
}
navigationController?.pushViewController(controller, animated: true)
}
}
///
/// marker
final class PunchPointMapView: UIView, MAMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
private let mapView: MAMapView
private var markerAnnotation: MAPointAnnotation?
override init(frame: CGRect) {
_ = AMapBootstrap.configureIfNeeded()
mapView = MAMapView(frame: .zero)
super.init(frame: frame)
mapView.delegate = self
mapView.showsUserLocation = true
mapView.showsCompass = false
mapView.showsScale = false
mapView.zoomLevel = 15
addSubview(mapView)
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func updateMarker(coordinate: CLLocationCoordinate2D?) {
if let markerAnnotation {
mapView.removeAnnotation(markerAnnotation)
self.markerAnnotation = nil
}
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
let annotation = MAPointAnnotation()
annotation.coordinate = coordinate
annotation.title = "打卡点位置"
markerAnnotation = annotation
mapView.addAnnotation(annotation)
}
///
func setCenter(_ coordinate: CLLocationCoordinate2D, zoomLevel: Double = 15, animated: Bool = true) {
mapView.setCenter(coordinate, animated: animated)
mapView.setZoomLevel(zoomLevel, animated: animated)
}
///
func zoomIn() {
mapView.setZoomLevel(mapView.zoomLevel + 1, animated: true)
}
///
func zoomOut() {
mapView.setZoomLevel(mapView.zoomLevel - 1, animated: true)
}
///
func centerOnUserLocation() {
guard let coordinate = mapView.userLocation.location?.coordinate,
CLLocationCoordinate2DIsValid(coordinate),
coordinate.latitude != 0 || coordinate.longitude != 0 else { return }
setCenter(coordinate)
}
func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) {
onMapTap?(coordinate)
}
}
///
///
final class PunchPointMapControlButton: UIButton {
init(symbol: String) {
super.init(frame: .zero)
setImage(UIImage(systemName: symbol), for: .normal)
tintColor = AppColor.textPrimary
backgroundColor = .white
layer.cornerRadius = 12
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
///
final class PunchPointReadOnlyField: UIView {
init(title: String, value: String, multiline: Bool = false) {
super.init(frame: .zero)
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 8
let titleLabel = UILabel()
titleLabel.attributedText = Self.requiredTitle(title)
let valueLabel = UILabel()
valueLabel.text = value.nonEmptyTrimmed ?? "--"
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
valueLabel.textColor = AppColor.textPrimary
valueLabel.numberOfLines = multiline ? 0 : 2
stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(valueLabel)
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private 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
}
}
///
///
final class PunchPointRowField: UIView {
init(title: String, value: String) {
super.init(frame: .zero)
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x2E3746)
let valueLabel = UILabel()
valueLabel.text = value.nonEmptyTrimmed ?? "--"
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
valueLabel.textColor = .black
valueLabel.textAlignment = .right
valueLabel.numberOfLines = 2
addSubview(titleLabel)
addSubview(valueLabel)
titleLabel.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0))
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
make.trailing.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 线
/// 线
final class PunchPointDivider: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(hex: 0xF0F0F0)
snp.makeConstraints { make in
make.height.equalTo(1)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension String {
var nonEmptyTrimmed: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}