690 lines
22 KiB
Swift
690 lines
22 KiB
Swift
import SwiftUI
|
||
|
||
/// 主上传视图
|
||
/// 提供媒体选择、预览和上传功能
|
||
@MainActor
|
||
struct MediaUploadView: View {
|
||
// MARK: - 属性
|
||
|
||
/// 上传管理器,负责处理上传逻辑
|
||
@StateObject private var uploadManager = MediaUploadManager()
|
||
/// 控制媒体选择器的显示/隐藏
|
||
@State private var showMediaPicker = false
|
||
/// 当前选中的媒体项
|
||
@State private var selectedMedia: MediaType? = nil
|
||
/// 当前选中的媒体索引集合
|
||
@State private var selectedIndices: Set<Int> = []
|
||
|
||
// MARK: - 视图主体
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
// 顶部导航栏
|
||
topNavigationBar
|
||
|
||
// 上传提示信息
|
||
uploadHintView
|
||
|
||
// 主上传区域
|
||
MainUploadArea(
|
||
uploadManager: uploadManager,
|
||
showMediaPicker: $showMediaPicker,
|
||
selectedMedia: $selectedMedia,
|
||
selectedIndices: $selectedIndices
|
||
)
|
||
.padding()
|
||
.id("mainUploadArea\(uploadManager.selectedMedia.count)")
|
||
|
||
Spacer()
|
||
|
||
// 继续按钮
|
||
continueButton
|
||
.padding(.bottom, 24)
|
||
}
|
||
.background(Color.themeTextWhiteSecondary)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.navigationBarBackButtonHidden(true)
|
||
.sheet(isPresented: $showMediaPicker) {
|
||
// 媒体选择器
|
||
mediaPickerView
|
||
}
|
||
.onChange(of: uploadManager.selectedMedia) { [oldMedia = uploadManager.selectedMedia] newMedia in
|
||
handleMediaChange(newMedia, oldMedia: oldMedia)
|
||
}
|
||
}
|
||
|
||
// MARK: - 子视图
|
||
|
||
/// 顶部导航栏
|
||
private var topNavigationBar: some View {
|
||
HStack {
|
||
// 返回按钮
|
||
Button(action: { Router.shared.pop() }) {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 17, weight: .semibold))
|
||
.foregroundColor(.themeTextMessageMain)
|
||
}
|
||
.padding(.leading, 16)
|
||
|
||
Spacer()
|
||
|
||
// 标题
|
||
Text("Complete Your Profile")
|
||
.font(Typography.font(for: .title2, family: .quicksandBold))
|
||
.foregroundColor(.themeTextMessageMain)
|
||
|
||
Spacer()
|
||
|
||
// 右侧占位视图(保持布局平衡)
|
||
Color.clear
|
||
.frame(width: 24, height: 24)
|
||
.padding(.trailing, 16)
|
||
}
|
||
.background(Color.themeTextWhiteSecondary)
|
||
.padding(.horizontal)
|
||
.zIndex(1) // 确保导航栏显示在最上层
|
||
}
|
||
|
||
/// 上传提示视图
|
||
private var uploadHintView: some View {
|
||
HStack {
|
||
Text("The upload process will take approximately 2 minutes. Thank you for your patience.")
|
||
.font(.caption)
|
||
.foregroundColor(.black)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(12)
|
||
.background(
|
||
LinearGradient(
|
||
gradient: Gradient(colors: [
|
||
Color(red: 1.0, green: 0.97, blue: 0.87),
|
||
.white,
|
||
Color(red: 1.0, green: 0.97, blue: 0.84)
|
||
]),
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
.cornerRadius(8)
|
||
)
|
||
.padding(.horizontal)
|
||
}
|
||
.padding(.vertical, 8)
|
||
}
|
||
|
||
/// 继续按钮
|
||
private var continueButton: some View {
|
||
Button(action: {
|
||
// 处理继续操作
|
||
// Router.shared.navigate(to: .avatarBox)
|
||
}) {
|
||
Text("Continue")
|
||
.font(.headline)
|
||
.foregroundColor(uploadManager.selectedMedia.isEmpty ? Color.themeTextMessage : Color.themeTextMessageMain)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 56)
|
||
.background(uploadManager.selectedMedia.isEmpty ? Color.white : Color.themePrimary)
|
||
.cornerRadius(28)
|
||
.padding(.horizontal, 24)
|
||
}
|
||
.buttonStyle(PlainButtonStyle())
|
||
.disabled(uploadManager.selectedMedia.isEmpty)
|
||
}
|
||
|
||
/// 媒体选择器视图
|
||
private var mediaPickerView: some View {
|
||
MediaPicker(
|
||
selectedMedia: $uploadManager.selectedMedia,
|
||
imageSelectionLimit: 20,
|
||
videoSelectionLimit: 5,
|
||
onDismiss: handleMediaPickerDismiss,
|
||
onUploadProgress: { index, progress in
|
||
print("文件 \(index) 上传进度: \(progress * 100)%")
|
||
}
|
||
)
|
||
}
|
||
|
||
// MARK: - 私有方法
|
||
|
||
/// 处理媒体选择器关闭事件
|
||
private func handleMediaPickerDismiss() {
|
||
showMediaPicker = false
|
||
print("媒体选择器关闭 - 开始处理")
|
||
|
||
// 如果有选中的媒体,开始上传
|
||
if !uploadManager.selectedMedia.isEmpty {
|
||
uploadManager.startUpload()
|
||
}
|
||
}
|
||
|
||
/// 处理媒体变化
|
||
/// - Parameters:
|
||
/// - newMedia: 新的媒体数组
|
||
/// - oldMedia: 旧的媒体数组
|
||
private func handleMediaChange(_ newMedia: [MediaType], oldMedia: [MediaType]) {
|
||
print("开始处理媒体变化,新数量: \(newMedia.count), 原数量: \(oldMedia.count)")
|
||
|
||
// 如果没有变化,直接返回
|
||
guard newMedia != oldMedia else {
|
||
print("媒体未发生变化,跳过处理")
|
||
return
|
||
}
|
||
|
||
// 在后台线程处理媒体变化
|
||
DispatchQueue.global(qos: .userInitiated).async {
|
||
let startTime = Date()
|
||
|
||
// 找出新增的媒体(在newMedia中但不在oldMedia中的项)
|
||
let newItems = newMedia.filter { newItem in
|
||
!oldMedia.contains { $0.id == newItem.id }
|
||
}
|
||
|
||
print("检测到\(newItems.count)个新增媒体项")
|
||
|
||
// 如果有新增媒体
|
||
if !newItems.isEmpty {
|
||
print("准备添加\(newItems.count)个新项...")
|
||
|
||
// 回到主线程更新UI状态
|
||
DispatchQueue.main.async {
|
||
// 如果当前没有选中的媒体,则选中第一个新增的媒体
|
||
if self.selectedIndices.isEmpty && !newItems.isEmpty {
|
||
self.selectedIndices = [self.uploadManager.selectedMedia.count] // 选择第一个新增项的索引
|
||
self.selectedMedia = newItems.first
|
||
}
|
||
|
||
// 开始上传新添加的媒体
|
||
self.uploadManager.startUpload()
|
||
print("媒体添加完成,总数量: \(self.uploadManager.selectedMedia.count)")
|
||
}
|
||
} else if newMedia.isEmpty {
|
||
// 清空选择
|
||
DispatchQueue.main.async {
|
||
self.selectedIndices = []
|
||
self.selectedMedia = nil
|
||
print("媒体已清空,重置选择状态")
|
||
}
|
||
}
|
||
|
||
print("媒体变化处理完成,总耗时: \(String(format: "%.3f", Date().timeIntervalSince(startTime)))s")
|
||
}
|
||
}
|
||
|
||
/// 检查是否有正在上传的文件
|
||
/// - Returns: 是否正在上传
|
||
private func isUploading() -> Bool {
|
||
return uploadManager.uploadStatus.values.contains { status in
|
||
if case .uploading = status { return true }
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 主上传区域
|
||
|
||
/// 主上传区域视图
|
||
/// 显示上传提示、媒体预览和添加更多按钮
|
||
struct MainUploadArea: View {
|
||
// MARK: - 属性
|
||
|
||
/// 上传管理器
|
||
@ObservedObject var uploadManager: MediaUploadManager
|
||
/// 控制媒体选择器的显示/隐藏
|
||
@Binding var showMediaPicker: Bool
|
||
/// 当前选中的媒体
|
||
@Binding var selectedMedia: MediaType?
|
||
/// 当前选中的媒体索引
|
||
@Binding var selectedIndices: Set<Int>
|
||
|
||
// MARK: - 视图主体
|
||
|
||
var body: some View {
|
||
VStack(spacing: 16) {
|
||
// 标题
|
||
Text("Click to upload 20 images and 5 videos to generate your next blind box.")
|
||
.font(Typography.font(for: .title2, family: .quicksandBold))
|
||
.fontWeight(.bold)
|
||
.foregroundColor(.black)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.horizontal)
|
||
|
||
// 上传提示视图
|
||
UploadPromptView(showMediaPicker: $showMediaPicker)
|
||
|
||
// 媒体预览区域
|
||
mediaPreviewSection
|
||
|
||
// 当没有选择媒体时显示添加更多按钮
|
||
if !uploadManager.selectedMedia.isEmpty {
|
||
addMoreButton
|
||
}
|
||
}
|
||
.background(Color.white)
|
||
.cornerRadius(16)
|
||
.shadow(radius: 2)
|
||
}
|
||
|
||
// MARK: - 子视图
|
||
|
||
/// 媒体预览区域
|
||
private var mediaPreviewSection: some View {
|
||
Group {
|
||
if !uploadManager.selectedMedia.isEmpty {
|
||
VStack(spacing: 8) {
|
||
// 已选择文件数量
|
||
Text("已选择 \(uploadManager.selectedMedia.count) 个文件")
|
||
.font(.subheadline)
|
||
.foregroundColor(.gray)
|
||
|
||
// 横向滚动的缩略图列表
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
LazyHStack(spacing: 10) {
|
||
ForEach(Array(uploadManager.selectedMedia.enumerated()), id: \.element.id) { index, media in
|
||
mediaItemView(for: media, at: index)
|
||
}
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
.frame(height: 140)
|
||
}
|
||
.padding(.vertical, 8)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 单个媒体项视图
|
||
/// - Parameters:
|
||
/// - media: 媒体项
|
||
/// - index: 索引
|
||
/// - Returns: 媒体项视图
|
||
private func mediaItemView(for media: MediaType, at index: Int) -> some View {
|
||
VStack(spacing: 4) {
|
||
// 媒体预览
|
||
MediaPreview(media: media, uploadManager: uploadManager)
|
||
.frame(width: 80, height: 80)
|
||
.cornerRadius(8)
|
||
.shadow(radius: 1)
|
||
.onTapGesture {
|
||
// 更新选中的媒体
|
||
selectedIndices = [index]
|
||
selectedMedia = media
|
||
}
|
||
|
||
// 上传状态指示器
|
||
uploadStatusView(for: index)
|
||
}
|
||
.padding(4)
|
||
}
|
||
|
||
/// 上传状态视图
|
||
/// - Parameter index: 媒体索引
|
||
/// - Returns: 状态视图
|
||
@ViewBuilder
|
||
private func uploadStatusView(for index: Int) -> some View {
|
||
if let status = uploadManager.uploadStatus["\(index)"] {
|
||
switch status {
|
||
case .uploading(let progress):
|
||
// 上传中,显示进度条
|
||
VStack(alignment: .center, spacing: 2) {
|
||
Text("\(Int(progress * 100))%")
|
||
.font(.caption2)
|
||
.foregroundColor(.gray)
|
||
ProgressView(value: progress, total: 1.0)
|
||
.progressViewStyle(LinearProgressViewStyle())
|
||
.frame(height: 3)
|
||
.tint(Color.themePrimary)
|
||
}
|
||
.frame(width: 60)
|
||
|
||
case .completed:
|
||
// 上传完成,显示完成图标
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.font(.caption)
|
||
.foregroundColor(.green)
|
||
|
||
case .failed:
|
||
// 上传失败,显示错误图标
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.font(.caption)
|
||
.foregroundColor(.red)
|
||
|
||
default:
|
||
EmptyView()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 添加更多按钮
|
||
private var addMoreButton: some View {
|
||
Button(action: { showMediaPicker = true }) {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "plus.circle.fill")
|
||
.font(.system(size: 30))
|
||
.foregroundColor(.themePrimary)
|
||
|
||
Text("Add More")
|
||
.font(.subheadline)
|
||
.foregroundColor(.gray)
|
||
}
|
||
.frame(width: 80, height: 80)
|
||
.background(Color.gray.opacity(0.1))
|
||
.cornerRadius(8)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.strokeBorder(style: StrokeStyle(
|
||
lineWidth: 2,
|
||
dash: [8, 4]
|
||
))
|
||
.foregroundColor(.gray.opacity(0.5))
|
||
)
|
||
.padding(4)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 上传提示视图
|
||
|
||
/// 上传提示视图
|
||
/// 显示上传区域的占位图和提示
|
||
struct UploadPromptView: View {
|
||
/// 控制媒体选择器的显示/隐藏
|
||
@Binding var showMediaPicker: Bool
|
||
|
||
var body: some View {
|
||
Button(action: { showMediaPicker = true }) {
|
||
// 上传图标
|
||
SVGImage(svgName: "IP")
|
||
.frame(width: 225, height: 225)
|
||
.contentShape(Rectangle())
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 20)
|
||
.stroke(style: StrokeStyle(
|
||
lineWidth: 5,
|
||
lineCap: .round,
|
||
dash: [12, 8]
|
||
))
|
||
.foregroundColor(Color.themePrimary)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 媒体预览视图
|
||
|
||
/// 媒体预览视图
|
||
/// 显示图片或视频的预览图
|
||
struct MediaPreview: View {
|
||
// MARK: - 属性
|
||
|
||
/// 图片处理队列
|
||
private let imageProcessingQueue = DispatchQueue(
|
||
label: "com.yourapp.imageprocessing",
|
||
qos: .userInitiated,
|
||
attributes: .concurrent
|
||
)
|
||
|
||
/// 媒体类型
|
||
let media: MediaType
|
||
/// 上传管理器
|
||
@ObservedObject var uploadManager: MediaUploadManager
|
||
|
||
// MARK: - 状态
|
||
|
||
/// 加载的图片
|
||
@State private var image: UIImage?
|
||
|
||
/// 加载状态
|
||
enum LoadState {
|
||
/// 加载成功
|
||
case success(UIImage)
|
||
/// 加载失败
|
||
case failure(Error)
|
||
}
|
||
|
||
/// 当前加载状态
|
||
@State private var loadState: LoadState?
|
||
|
||
// MARK: - 图片缓存
|
||
|
||
/// 图片缓存
|
||
private struct ImageCache {
|
||
static let shared = NSCache<NSString, UIImage>()
|
||
}
|
||
|
||
// MARK: - 计算属性
|
||
|
||
/// 上传进度
|
||
private var uploadProgress: Double {
|
||
guard let index = uploadManager.selectedMedia.firstIndex(where: { $0.id == media.id }) else {
|
||
return 0
|
||
}
|
||
|
||
if case .uploading(let progress) = uploadManager.uploadStatus["\(index)"] {
|
||
return progress
|
||
} else if case .completed = uploadManager.uploadStatus["\(index)"] {
|
||
return 1.0
|
||
}
|
||
return 0
|
||
}
|
||
|
||
/// 是否正在上传
|
||
private var isUploading: Bool {
|
||
guard let index = uploadManager.selectedMedia.firstIndex(where: { $0.id == media.id }) else {
|
||
return false
|
||
}
|
||
|
||
if case .uploading = uploadManager.uploadStatus["\(index)"] {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// MARK: - 视图主体
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
// 显示图片或错误状态
|
||
if let image = image {
|
||
loadedImageView(image)
|
||
|
||
// 视频播放按钮
|
||
if case .video = media {
|
||
playButton
|
||
}
|
||
|
||
// 上传进度指示器(仅在上传时显示)
|
||
if isUploading || uploadProgress > 0 {
|
||
loadingOverlay
|
||
}
|
||
} else if case .failure(let error) = loadState {
|
||
// 加载失败状态
|
||
errorView(error: error)
|
||
} else {
|
||
// 初始加载时显示占位图
|
||
placeholderView
|
||
.onAppear {
|
||
loadImage()
|
||
}
|
||
}
|
||
}
|
||
.aspectRatio(1, contentMode: .fill)
|
||
.clipped()
|
||
.cornerRadius(8)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(Color.themePrimary.opacity(0.3), lineWidth: 1)
|
||
)
|
||
}
|
||
|
||
// MARK: - 子视图
|
||
|
||
/// 加载中的占位图
|
||
private var placeholderView: some View {
|
||
Color.gray.opacity(0.1)
|
||
.overlay(
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle())
|
||
)
|
||
}
|
||
|
||
/// 加载完成的图片视图
|
||
private func loadedImageView(_ image: UIImage) -> some View {
|
||
Image(uiImage: image)
|
||
.resizable()
|
||
.scaledToFill()
|
||
.transition(.opacity.animation(.easeInOut(duration: 0.2)))
|
||
}
|
||
|
||
/// 播放按钮
|
||
private var playButton: some View {
|
||
Image(systemName: "play.circle.fill")
|
||
.font(.system(size: 24))
|
||
.foregroundColor(.white)
|
||
.shadow(radius: 4)
|
||
}
|
||
|
||
/// 错误视图
|
||
private func errorView(error: Error) -> some View {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "exclamationmark.triangle")
|
||
.font(.system(size: 20))
|
||
.foregroundColor(.orange)
|
||
|
||
Text("加载失败")
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
|
||
Button(action: {
|
||
loadState = nil
|
||
loadImage()
|
||
}) {
|
||
Image(systemName: "arrow.clockwise")
|
||
.font(.caption)
|
||
.padding(4)
|
||
.background(Color.gray.opacity(0.2))
|
||
.clipShape(Circle())
|
||
}
|
||
.padding(.top, 4)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.background(Color.gray.opacity(0.1))
|
||
.cornerRadius(8)
|
||
}
|
||
|
||
/// 上传进度遮罩
|
||
private var loadingOverlay: some View {
|
||
ZStack {
|
||
// 半透明黑色背景
|
||
Color.black.opacity(0.3)
|
||
|
||
// 进度条
|
||
VStack {
|
||
Spacer()
|
||
|
||
// 圆形进度指示器
|
||
ZStack {
|
||
Circle()
|
||
.stroke(
|
||
Color.white.opacity(0.3),
|
||
lineWidth: 4
|
||
)
|
||
|
||
Circle()
|
||
.trim(from: 0.0, to: uploadProgress)
|
||
.stroke(
|
||
Color.white,
|
||
style: StrokeStyle(
|
||
lineWidth: 4,
|
||
lineCap: .round
|
||
)
|
||
)
|
||
.rotationEffect(.degrees(-90))
|
||
|
||
Text("\(Int(uploadProgress * 100))%")
|
||
.font(.system(size: 12, weight: .bold))
|
||
.foregroundColor(.white)
|
||
}
|
||
.frame(width: 40, height: 40)
|
||
.padding(.bottom, 8)
|
||
}
|
||
}
|
||
.cornerRadius(8)
|
||
}
|
||
|
||
// MARK: - 私有方法
|
||
|
||
/// 加载图片
|
||
private func loadImage() {
|
||
let cacheKey = "\(media.id)" as NSString
|
||
|
||
// 检查缓存
|
||
if let cachedImage = ImageCache.shared.object(forKey: cacheKey) {
|
||
self.image = cachedImage
|
||
self.loadState = .success(cachedImage)
|
||
return
|
||
}
|
||
|
||
// 使用专用的图片处理队列
|
||
imageProcessingQueue.async {
|
||
do {
|
||
let imageToCache: UIImage
|
||
|
||
switch self.media {
|
||
case .image(let uiImage):
|
||
imageToCache = uiImage
|
||
|
||
case .video(_, let thumbnail):
|
||
guard let thumbnail = thumbnail else {
|
||
throw NSError(
|
||
domain: "com.yourapp.media",
|
||
code: -1,
|
||
userInfo: [NSLocalizedDescriptionKey: "视频缩略图加载失败"]
|
||
)
|
||
}
|
||
imageToCache = thumbnail
|
||
}
|
||
|
||
// 缓存图片
|
||
ImageCache.shared.setObject(imageToCache, forKey: cacheKey)
|
||
|
||
// 更新UI
|
||
DispatchQueue.main.async {
|
||
withAnimation(.easeInOut(duration: 0.2)) {
|
||
self.image = imageToCache
|
||
self.loadState = .success(imageToCache)
|
||
}
|
||
}
|
||
|
||
} catch {
|
||
print("图片加载失败: \(error.localizedDescription)")
|
||
DispatchQueue.main.async {
|
||
self.loadState = .failure(error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 扩展
|
||
|
||
/// 扩展 MediaType 以支持 Identifiable 协议
|
||
extension MediaType: Identifiable {
|
||
/// 唯一标识符
|
||
public var id: String {
|
||
switch self {
|
||
case .image(let uiImage):
|
||
return "image_\(uiImage.hashValue)"
|
||
case .video(let url, _):
|
||
return "video_\(url.absoluteString)"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 预览
|
||
|
||
struct MediaUploadView_Previews: PreviewProvider {
|
||
static var previews: some View {
|
||
NavigationView {
|
||
MediaUploadView()
|
||
}
|
||
}
|
||
}
|