529 lines
18 KiB
Swift
529 lines
18 KiB
Swift
//
|
||
// 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.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4)
|
||
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 举报模块附件缩略 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")
|
||
}
|
||
}
|