对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
545 lines
19 KiB
Swift
545 lines
19 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.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")
|
|
}
|
|
}
|