wake-ios/wake/View/Upload/MediaUploadView.swift
2025-08-25 18:58:10 +08:00

690 lines
22 KiB
Swift
Raw 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.

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()
// newMediaoldMedia
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()
}
}
}