Files
suixinkan_uikit/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift
汉秋 efb3925257 完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:46:45 +08:00

545 lines
19 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.

//
// WildReportCommonViews.swift
// suixinkan
//
import AVFoundation
import PhotosUI
import SnapKit
import UIKit
import UniformTypeIdentifiers
/// UI
enum WildReportUI {
static func label(_ text: String, font: UIFont, color: UIColor, lines: Int = 1) -> UILabel {
let label = UILabel()
label.text = text
label.font = font
label.textColor = color
label.numberOfLines = lines
return label
}
static func iconButton(title: String, imageName: String, style: AppButton.Style = .primary) -> UIButton {
let button = UIButton(type: .system)
button.titleLabel?.font = .app(.subtitle)
button.setTitle(title, for: .normal)
button.setImage(UIImage(systemName: imageName), for: .normal)
button.tintColor = style == .primary ? .white : AppColor.primary
button.setTitleColor(style == .primary ? .white : AppColor.primary, for: .normal)
button.backgroundColor = style == .primary ? AppColor.primary : AppColor.primaryLight
button.layer.cornerRadius = AppRadius.md
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)
)
button.configuration?.imagePlacement = .leading
button.configuration?.imagePadding = 4
return button
}
}
///
final class WildReportCardView: UIView {
let stack = UIStackView()
init(spacing: CGFloat = AppSpacing.sm, inset: CGFloat = AppSpacing.md) {
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = AppRadius.lg
layer.borderWidth = 1
layer.borderColor = AppColor.cardOutline.cgColor
stack.axis = .vertical
stack.spacing = max(0, spacing - AppSpacing.xxs)
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(inset)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
final class WildReportStatusView: UILabel {
override init(frame: CGRect) {
super.init(frame: frame)
font = .app(.captionMedium)
textAlignment = .center
layer.cornerRadius = 12
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(status: WildReportStatus) {
text = status.rawValue
textColor = status.displayColor
backgroundColor = status.backgroundColor
}
}
///
final class WildReportInfoRowView: UIView {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
init(title: String, value: String) {
super.init(frame: .zero)
titleLabel.font = .app(.body)
titleLabel.textColor = AppColor.textSecondary
titleLabel.text = title
valueLabel.font = .app(.bodyMedium)
valueLabel.textColor = AppColor.textPrimary
valueLabel.textAlignment = .right
valueLabel.numberOfLines = 0
valueLabel.text = value
addSubview(titleLabel)
addSubview(valueLabel)
titleLabel.snp.makeConstraints { make in
make.leading.top.equalToSuperview()
make.width.greaterThanOrEqualTo(76)
make.bottom.lessThanOrEqualToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(AppSpacing.sm)
make.trailing.top.bottom.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// chip
final class WildReportAttachmentChipView: UIView {
private let titleLabel = UILabel()
private let iconView = UIImageView()
var onRemove: (() -> Void)?
init(attachment: WildReportAttachment, removable: Bool = true) {
super.init(frame: .zero)
backgroundColor = AppColor.pageBackgroundSoft
layer.cornerRadius = AppRadius.md
layer.borderWidth = 1
layer.borderColor = AppColor.cardOutline.cgColor
iconView.image = UIImage(systemName: iconName(for: attachment.kind))
iconView.tintColor = AppColor.primary
titleLabel.text = attachment.title
titleLabel.font = .app(.captionMedium)
titleLabel.textColor = AppColor.textPrimary
addSubview(iconView)
addSubview(titleLabel)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(AppSpacing.sm)
make.centerY.equalToSuperview()
make.size.equalTo(18)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.xs)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualToSuperview().inset(removable ? 34 : AppSpacing.sm)
}
if removable {
let button = UIButton(type: .system)
button.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
button.tintColor = AppColor.textTertiary
button.addTarget(self, action: #selector(removeTapped), for: .touchUpInside)
addSubview(button)
button.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.xs)
make.centerY.equalToSuperview()
make.size.equalTo(28)
}
}
snp.makeConstraints { make in
make.height.equalTo(40)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func iconName(for kind: WildReportAttachmentKind) -> String {
switch kind {
case .image: return "photo.fill"
case .video: return "video.fill"
case .payment: return "creditcard.fill"
}
}
@objc private func removeTapped() {
onRemove?()
}
}
///
enum WildReportPickerTarget: Equatable {
case image
case video
case payment
init(kind: WildReportAttachmentKind) {
switch kind {
case .image: self = .image
case .video: self = .video
case .payment: self = .payment
}
}
var attachmentKind: WildReportAttachmentKind {
switch self {
case .image: return .image
case .video: return .video
case .payment: return .payment
}
}
var titlePrefix: String {
switch self {
case .image: return "现场图片"
case .video: return "现场视频"
case .payment: return "支付截图"
}
}
var limitMessage: String {
switch self {
case .image: return "现场证据最多上传9项"
case .video: return "现场视频最多上传3段"
case .payment: return "支付截图最多上传3张"
}
}
}
/// PHPicker /
enum WildReportLocalMediaLoader {
static func load(
results: [PHPickerResult],
target: WildReportPickerTarget,
titlePrefix: String? = nil
) async throws -> [WildReportAttachment] {
var attachments: [WildReportAttachment] = []
for result in results {
let attachment = target == .video
? try await loadVideo(from: result.itemProvider, target: target, titlePrefix: titlePrefix)
: try await loadImage(from: result.itemProvider, target: target, titlePrefix: titlePrefix)
attachments.append(attachment)
}
return attachments
}
private static func loadImage(
from provider: NSItemProvider,
target: WildReportPickerTarget,
titlePrefix: String?
) async throws -> WildReportAttachment {
try await withCheckedThrowingContinuation { continuation in
guard provider.canLoadObject(ofClass: UIImage.self) else {
continuation.resume(throwing: OSSUploadError.unsupportedFileType)
return
}
provider.loadObject(ofClass: UIImage.self) { object, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let image = object as? UIImage,
let data = image.jpegData(compressionQuality: 0.9) else {
continuation.resume(throwing: OSSUploadError.emptyFile)
return
}
do {
let fileName = "report_image_\(UUID().uuidString).jpg"
let url = try write(data: data, fileName: fileName)
continuation.resume(returning: WildReportAttachment(
kind: target.attachmentKind,
title: titlePrefix ?? target.titlePrefix,
localFileURL: url,
fileName: fileName
))
} catch {
continuation.resume(throwing: error)
}
}
}
}
private static func loadVideo(
from provider: NSItemProvider,
target: WildReportPickerTarget,
titlePrefix: String?
) async throws -> WildReportAttachment {
try await withCheckedThrowingContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let url else {
continuation.resume(throwing: OSSUploadError.emptyFile)
return
}
do {
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
let fileName = "report_video_\(UUID().uuidString).\(ext)"
let targetURL = try temporaryFileURL(fileName: fileName)
if FileManager.default.fileExists(atPath: targetURL.path) {
try FileManager.default.removeItem(at: targetURL)
}
try FileManager.default.copyItem(at: url, to: targetURL)
continuation.resume(returning: WildReportAttachment(
kind: target.attachmentKind,
title: titlePrefix ?? target.titlePrefix,
localFileURL: targetURL,
fileName: fileName
))
} catch {
continuation.resume(throwing: error)
}
}
}
}
private static func write(data: Data, fileName: String) throws -> URL {
let url = try temporaryFileURL(fileName: fileName)
try data.write(to: url, options: .atomic)
return url
}
private static func temporaryFileURL(fileName: String) throws -> URL {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("wild_report_media", isDirectory: true)
if !FileManager.default.fileExists(atPath: directory.path) {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
return directory.appendingPathComponent(fileName)
}
}
extension MediaPreviewItem {
///
init?(wildReportAttachment attachment: WildReportAttachment) {
guard let url = attachment.localFileURL else { return nil }
switch attachment.kind {
case .image, .payment:
self = .localImage(url: url)
case .video:
self = .localVideo(url: url)
}
}
}
/// tile
final class WildReportAttachmentTileView: UIControl {
private let gradientLayer = CAGradientLayer()
private let previewAction: () -> Void
private let removeAction: () -> Void
private let tileSize: CGFloat
init(
attachment: WildReportAttachment,
tileSize: CGFloat = 58,
onPreview: @escaping () -> Void,
onRemove: @escaping () -> Void
) {
self.previewAction = onPreview
self.removeAction = onRemove
self.tileSize = tileSize
super.init(frame: .zero)
layer.cornerRadius = 10
clipsToBounds = false
gradientLayer.cornerRadius = 10
let colors: [UIColor] = attachment.kind == .video
? [UIColor(hex: 0xB7D8FF), UIColor(hex: 0x4C8FEF)]
: [UIColor(hex: 0xCFE6FF), UIColor(hex: 0x7EB4FF)]
gradientLayer.colors = colors.map(\.cgColor)
gradientLayer.startPoint = CGPoint(x: 0, y: 0)
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
layer.insertSublayer(gradientLayer, at: 0)
let thumbnailView = UIImageView()
thumbnailView.image = WildReportAttachmentThumbnailFactory.thumbnail(for: attachment)
thumbnailView.contentMode = .scaleAspectFill
thumbnailView.clipsToBounds = true
thumbnailView.layer.cornerRadius = 10
thumbnailView.isUserInteractionEnabled = false
thumbnailView.isHidden = thumbnailView.image == nil
addSubview(thumbnailView)
thumbnailView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
let icon = UIImageView(image: UIImage(systemName: attachment.kind == .video ? "play.circle.fill" : "mountain.2.fill"))
icon.tintColor = UIColor.white.withAlphaComponent(0.95)
icon.contentMode = .scaleAspectFit
icon.isHidden = attachment.kind != .video && thumbnailView.image != nil
addSubview(icon)
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(attachment.kind == .video ? 30 : 26)
}
let removeButton = UIButton(type: .system)
removeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
removeButton.setPreferredSymbolConfiguration(UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold), forImageIn: .normal)
removeButton.tintColor = UIColor(hex: 0x8D93A1)
removeButton.backgroundColor = .white
removeButton.layer.cornerRadius = 14
removeButton.layer.shadowColor = UIColor.black.cgColor
removeButton.layer.shadowOpacity = 0.10
removeButton.layer.shadowRadius = 4
removeButton.layer.shadowOffset = CGSize(width: 0, height: 2)
removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside)
addSubview(removeButton)
removeButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(-2)
make.size.equalTo(34)
}
addTarget(self, action: #selector(previewTapped), for: .touchUpInside)
snp.makeConstraints { make in
make.size.equalTo(tileSize)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
}
@objc private func previewTapped() {
previewAction()
}
@objc private func removeTapped() {
removeAction()
}
}
///
enum WildReportAttachmentThumbnailFactory {
static func thumbnail(for attachment: WildReportAttachment) -> UIImage? {
guard let url = attachment.localFileURL else { return nil }
switch attachment.kind {
case .image, .payment:
return UIImage(contentsOfFile: url.path)
case .video:
return videoThumbnail(url: url)
}
}
private static func videoThumbnail(url: URL) -> UIImage? {
let asset = AVURLAsset(url: url)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 160, height: 160)
do {
let cgImage = try generator.copyCGImage(at: CMTime(seconds: 0.1, preferredTimescale: 600), actualTime: nil)
return UIImage(cgImage: cgImage)
} catch {
return nil
}
}
}
/// tile线
final class WildReportAddAttachmentTileView: UIControl {
private let tapAction: () -> Void
private let borderLayer = CAShapeLayer()
private let tileSize: CGFloat
init(
tileSize: CGFloat = 58,
borderColor: UIColor = UIColor(hex: 0xCDD5E1),
iconColor: UIColor = UIColor(hex: 0x8D93A1),
action: @escaping () -> Void
) {
self.tapAction = action
self.tileSize = tileSize
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = 10
borderLayer.strokeColor = borderColor.cgColor
borderLayer.fillColor = UIColor.clear.cgColor
borderLayer.lineWidth = 1
borderLayer.lineDashPattern = [5, 4]
layer.addSublayer(borderLayer)
let icon = UIImageView(image: UIImage(systemName: "plus"))
icon.tintColor = iconColor
icon.contentMode = .scaleAspectFit
addSubview(icon)
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(30)
}
addTarget(self, action: #selector(tapped), for: .touchUpInside)
snp.makeConstraints { make in
make.size.equalTo(tileSize)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
borderLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath
}
@objc private func tapped() {
tapAction()
}
}
///
final class WildReportEmptyView: UIView {
init(text: String) {
super.init(frame: .zero)
let icon = UIImageView(image: UIImage(systemName: "tray"))
icon.tintColor = AppColor.textTertiary
let label = WildReportUI.label(text, font: .app(.body), color: AppColor.textSecondary, lines: 0)
label.textAlignment = .center
addSubview(icon)
addSubview(label)
icon.snp.makeConstraints { make in
make.top.centerX.equalToSuperview()
make.size.equalTo(42)
}
label.snp.makeConstraints { make in
make.top.equalTo(icon.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.bottom.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}