Files
suixinkan_uikit/suixinkan/UI/PunchPoint/PunchPointDetailViewController.swift
汉秋 5138c1c11a 完善实名认证、钱包与打卡点模块 UI 与业务逻辑。
对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 16:28:10 +08:00

762 lines
30 KiB
Swift
Raw Permalink 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.

//
// 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(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 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(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()
}
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.updatePunchPointMarker(
coordinate: coordinate,
imageURL: detail.guideImages.first,
fitWithUserLocation: true
)
}
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,
showsLocationIcon: true
))
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, placeholder: UIImage(named: "SplashLogo"))
} else {
imageView.image = UIImage(named: "SplashLogo")
}
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(PunchPointDisplayFormatter.auditStatus(detail.status), compact: false)
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
section.addArrangedSubview(title)
title.snp.makeConstraints { make in
make.width.equalTo(section.snp.width)
}
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, placeholder: UIImage(named: "SplashLogo"))
} else {
imageView.image = UIImage(named: "SplashLogo")
}
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 authorization = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
guard authorization == .authorized || authorization == .limited else {
await MainActor.run { self.presentPhotoPermissionAlert() }
return
}
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) }
}
}
}
@MainActor
private func presentPhotoPermissionAlert() {
let alert = UIAlertController(
title: "无法保存二维码",
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)
}
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() {
Task {
do {
let snapshot = try await LocationProvider.shared.requestSnapshot()
let coordinate = CLLocationCoordinate2D(
latitude: snapshot.latitude,
longitude: snapshot.longitude
)
await MainActor.run { self.mapView.setCenter(coordinate, zoomLevel: 16) }
} catch LocationProviderError.permissionDenied {
await MainActor.run { self.presentLocationPermissionAlert() }
} catch {
await MainActor.run { self.showToast("定位失败") }
}
}
}
@MainActor
private func presentLocationPermissionAlert() {
let alert = UIAlertController(
title: "无法定位",
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)
}
@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 punchPointAnnotation: MAPointAnnotation?
private var selectedAnnotation: MAPointAnnotation?
private var punchPointImageURL: URL?
private var fitWithUserLocation = false
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.touchPOIEnabled = true
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")
}
/// 使 Android Marker
func updatePunchPointMarker(
coordinate: CLLocationCoordinate2D?,
imageURL: String? = nil,
fitWithUserLocation: Bool = false
) {
if let punchPointAnnotation {
mapView.removeAnnotation(punchPointAnnotation)
self.punchPointAnnotation = nil
}
punchPointImageURL = imageURL.flatMap(URL.init(string:))
self.fitWithUserLocation = fitWithUserLocation
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
let annotation = MAPointAnnotation()
annotation.coordinate = coordinate
annotation.title = "punch_point_original"
punchPointAnnotation = annotation
mapView.addAnnotation(annotation)
if fitWithUserLocation {
fitPunchPointAndUserLocation()
}
}
/// Marker
func updateMarker(coordinate: CLLocationCoordinate2D?) {
updatePunchPointMarker(coordinate: coordinate)
}
/// Marker
func updateSelectedMarker(coordinate: CLLocationCoordinate2D?) {
if let selectedAnnotation {
mapView.removeAnnotation(selectedAnnotation)
self.selectedAnnotation = nil
}
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
let annotation = MAPointAnnotation()
annotation.coordinate = coordinate
annotation.title = "punch_point_selected"
selectedAnnotation = 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)
}
/// Android
func fitAllMarkers(edgePadding: UIEdgeInsets = UIEdgeInsets(top: 80, left: 56, bottom: 80, right: 56)) {
var annotations: [MAAnnotation] = []
if let punchPointAnnotation { annotations.append(punchPointAnnotation) }
if let selectedAnnotation { annotations.append(selectedAnnotation) }
if let userLocation = mapView.userLocation,
let location = userLocation.location,
CLLocationCoordinate2DIsValid(location.coordinate) {
annotations.append(userLocation)
}
guard !annotations.isEmpty else { return }
if annotations.count == 1, let annotation = annotations.first {
setCenter(annotation.coordinate, zoomLevel: 16)
} else {
mapView.showAnnotations(annotations, edgePadding: edgePadding, animated: true)
}
}
private func fitPunchPointAndUserLocation() {
guard fitWithUserLocation else { return }
fitAllMarkers(edgePadding: UIEdgeInsets(top: 80, left: 50, bottom: 80, right: 50))
}
func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! {
if annotation is MAUserLocation { return nil }
let isOriginal = annotation === punchPointAnnotation
let reuseIdentifier = isOriginal ? "PunchPointImageMarker" : "PunchPointSelectedMarker"
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseIdentifier)
?? MAAnnotationView(annotation: annotation, reuseIdentifier: reuseIdentifier)
annotationView?.annotation = annotation
annotationView?.canShowCallout = false
annotationView?.centerOffset = CGPoint(x: 0, y: -12)
if isOriginal, let punchPointImageURL {
annotationView?.image = UIImage(named: "punch_point_marker")
KingfisherManager.shared.retrieveImage(with: punchPointImageURL) { result in
guard case let .success(value) = result else { return }
Task { @MainActor in
guard annotationView?.annotation === annotation else { return }
annotationView?.image = Self.roundedMarkerImage(value.image)
}
}
} else {
annotationView?.image = UIImage(named: "punch_point_marker")
}
return annotationView
}
func mapView(_ mapView: MAMapView!, didUpdate userLocation: MAUserLocation!, updatingLocation: Bool) {
guard updatingLocation, userLocation.location != nil else { return }
fitPunchPointAndUserLocation()
}
private static func roundedMarkerImage(_ source: UIImage) -> UIImage {
let size = CGSize(width: 40, height: 40)
return UIGraphicsImageRenderer(size: size).image { _ in
UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 6).addClip()
source.draw(in: CGRect(origin: .zero, size: size))
}
}
func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) {
onMapTap?(coordinate)
}
func mapView(_ mapView: MAMapView!, didTouchPois pois: [Any]!) {
guard let poi = pois.first as? MATouchPoi else { return }
onMapTap?(poi.coordinate)
}
}
///
///
final class PunchPointMapControlButton: UIButton {
init(assetName: String) {
super.init(frame: .zero)
setImage(UIImage(named: assetName), for: .normal)
backgroundColor = .white
layer.cornerRadius = 12
clipsToBounds = true
imageView?.contentMode = .scaleAspectFit
accessibilityLabel = switch assetName {
case "punch_point_zoom_in": "放大地图"
case "punch_point_zoom_out": "缩小地图"
default: "定位当前位置"
}
}
@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, showsLocationIcon: Bool = false) {
super.init(frame: .zero)
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 8
let titleLabel = UILabel()
titleLabel.attributedText = Self.requiredTitle(title)
let valueContainer = UIView()
valueContainer.backgroundColor = UIColor(hex: 0xF4F4F4)
valueContainer.layer.cornerRadius = 8
let valueLabel = UILabel()
valueLabel.text = value.nonEmptyTrimmed ?? "--"
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = UIColor(hex: 0x4B5563)
valueLabel.numberOfLines = multiline ? 0 : 2
stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(valueContainer)
valueContainer.addSubview(valueLabel)
if showsLocationIcon {
let icon = UIImageView(image: UIImage(named: "punch_point_location"))
icon.contentMode = .scaleAspectFit
valueContainer.addSubview(icon)
icon.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
make.size.equalTo(20)
}
valueLabel.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(12)
make.leading.equalToSuperview().offset(12)
make.trailing.lessThanOrEqualTo(icon.snp.leading).offset(-8)
}
} else {
valueLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12))
}
}
valueContainer.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(multiline ? 72 : 46)
}
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
}
}