wake-ios/wake/View/Upload/MediaUploadView.swift
2025-08-27 20:19:25 +08:00

862 lines
31 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
import AVFoundation
extension Notification.Name {
static let didAddFirstMedia = Notification.Name("didAddFirstMedia")
}
///
///
@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> = []
@State private var mediaPickerSelection: [MediaType] = [] //
// MARK: -
var body: some View {
VStack(spacing: 0) {
//
topNavigationBar
//
uploadHintView
Spacer()
.frame(height: uploadManager.selectedMedia.isEmpty ? 80 : 40)
//
MainUploadArea(
uploadManager: uploadManager,
showMediaPicker: $showMediaPicker,
selectedMedia: $selectedMedia
)
.id("mainUploadArea\(uploadManager.selectedMedia.count)")
Spacer()
//
continueButton
.padding(.bottom, 24)
}
.background(Color.themeTextWhiteSecondary)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.sheet(isPresented: $showMediaPicker) {
//
mediaPickerView
}
}
// 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(3)
.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: .blindBox(mediaType: .video))
}) {
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: Binding(
get: { mediaPickerSelection },
set: { newSelections in
//
let newItems = newSelections.filter { newItem in
!uploadManager.selectedMedia.contains { $0.id == newItem.id }
}
if !newItems.isEmpty {
//
let newMedia = newItems + uploadManager.selectedMedia
uploadManager.clearAllMedia()
uploadManager.addMedia(newMedia)
//
if selectedIndices.isEmpty {
selectedIndices = [0] //
selectedMedia = newItems.first
}
//
uploadManager.startUpload()
}
//
mediaPickerSelection = []
}
),
imageSelectionLimit: max(0, 20 - uploadManager.selectedMedia.filter {
if case .image = $0 { return true }
return false
}.count),
videoSelectionLimit: max(0, 5 - uploadManager.selectedMedia.filter {
if case .video = $0 { return true }
return false
}.count),
selectionMode: .multiple,
onDismiss: handleMediaPickerDismiss,
onUploadProgress: { index, progress in
print("文件 \(index) 上传进度: \(progress * 100)%")
}
)
.onAppear {
//
mediaPickerSelection = []
}
}
// MARK: -
///
private func handleMediaPickerDismiss() {
showMediaPicker = false
print("媒体选择器关闭 - 开始处理")
//
if !uploadManager.selectedMedia.isEmpty {
// handleMediaChange
}
}
///
/// - 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 { [self] in
// 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 { [self] in
//
var updatedMedia = uploadManager.selectedMedia
updatedMedia.append(contentsOf: newItems)
//
uploadManager.clearAllMedia()
uploadManager.addMedia(updatedMedia)
//
if selectedIndices.isEmpty && !newItems.isEmpty {
selectedIndices = [oldMedia.count] //
selectedMedia = newItems.first
}
//
uploadManager.startUpload()
print("媒体添加完成,总数量: \(uploadManager.selectedMedia.count)")
}
}
}
}
///
/// - 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?
// MARK: -
var body: some View {
VStack() {
Spacer()
.frame(height: 30)
//
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)
Spacer()
.frame(height: 50)
//
if let mediaToDisplay = selectedMedia ?? uploadManager.selectedMedia.first {
MediaPreview(media: mediaToDisplay, uploadManager: uploadManager)
.id(mediaToDisplay.id)
.frame(width: 225, height: 225)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.themePrimary, lineWidth: 5)
)
.cornerRadius(16)
.shadow(radius: 4)
.padding(.horizontal)
.transition(.opacity)
} else {
UploadPromptView(showMediaPicker: $showMediaPicker)
}
//
mediaPreviewSection
Spacer()
.frame(height: 10)
}
.onAppear {
print("MainUploadArea appeared")
print("Selected media count: \(uploadManager.selectedMedia.count)")
if selectedMedia == nil, let firstMedia = uploadManager.selectedMedia.first {
print("Selecting first media: \(firstMedia.id)")
selectedMedia = firstMedia
}
}
.onReceive(NotificationCenter.default.publisher(for: .didAddFirstMedia)) { notification in
if let media = notification.userInfo?["media"] as? MediaType, selectedMedia == nil {
selectedMedia = media
}
}
.background(Color.white)
.cornerRadius(18)
.animation(.default, value: selectedMedia?.id)
}
// MARK: -
///
private var mediaPreviewSection: some View {
Group {
if !uploadManager.selectedMedia.isEmpty {
VStack(spacing: 4) {
//
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 10) {
ForEach(Array(uploadManager.selectedMedia.enumerated()), id: \.element.id) { index, media in
mediaItemView(for: media, at: index)
}
//
if !uploadManager.selectedMedia.isEmpty {
addMoreButton
}
}
.padding(.horizontal)
}
.frame(height: 70)
}
.padding(.top, 10)
}
}
}
///
/// - Parameters:
/// - media:
/// - index:
/// - Returns:
private func mediaItemView(for media: MediaType, at index: Int) -> some View {
ZStack(alignment: .topTrailing) {
VStack(spacing: 4) {
//
MediaPreview(media: media, uploadManager: uploadManager)
.frame(width: 58, height: 58)
.cornerRadius(8)
.shadow(radius: 1)
.overlay(
//
ZStack(alignment: .topLeading) {
//
ZStack(alignment: .topLeading) {
Path { path in
let radius: CGFloat = 4
let width: CGFloat = 14
let height: CGFloat = 10
//
path.move(to: CGPoint(x: 0, y: radius))
path.addQuadCurve(to: CGPoint(x: radius, y: 0),
control: CGPoint(x: 0, y: 0))
//
path.addLine(to: CGPoint(x: width, y: 0))
//
path.addLine(to: CGPoint(x: width, y: height - radius))
//
path.addQuadCurve(to: CGPoint(x: width - radius, y: height),
control: CGPoint(x: width, y: height))
//
path.addLine(to: CGPoint(x: 0, y: height))
//
path.closeSubpath()
}
.fill(Color(hex: "BEBEBE").opacity(0.6))
Text("\(index + 1)")
.font(.system(size: 8, weight: .bold))
.foregroundColor(.black)
.frame(width: 14, height: 10)
.offset(y: -1)
}
.frame(width: 14, height: 10, alignment: .topLeading)
.padding([.top, .leading], 2)
//
if case .video(let url, _) = media, let videoURL = url as? URL {
VStack {
Spacer()
HStack {
Spacer()
Text(getVideoDuration(url: videoURL))
.font(.system(size: 8, weight: .bold))
.foregroundColor(.black)
.padding(.horizontal, 4)
.frame(height: 10)
.background(Color(hex: "BEBEBE").opacity(0.6))
.cornerRadius(2)
}
.padding([.trailing, .bottom], 0)
}
} else {
VStack {
Spacer()
HStack {
Spacer()
Text("")
.font(.system(size: 8, weight: .bold))
.foregroundColor(.black)
.padding(.horizontal, 4)
.frame(height: 10)
.background(Color(hex: "BEBEBE").opacity(0.6))
.cornerRadius(2)
}
.padding([.trailing, .bottom], 0)
}
.opacity(0)
}
}
)
// mediaItemView onTapGesture
// .onTapGesture {
// print("Tapped media at index: \(index)") //
// withAnimation {
// selectedIndices = [index]
// selectedMedia = media
// }
// }
// .contentShape(Rectangle()) //
// mediaItemView onTapGesture
.onTapGesture {
print("点击了媒体项,索引: \(index)")
withAnimation {
selectedMedia = media //
}
}
.contentShape(Rectangle()) //
}
//
Button(action: {
// 使API
uploadManager.removeMedia(id: media.id)
//
if selectedMedia == media {
selectedMedia = nil
}
}) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.black)
.frame(width: 12, height: 12)
.background(
Circle()
.fill(Color(hex: "BEBEBE").opacity(0.6))
.frame(width: 12, height: 12)
)
}
.offset(x: 6, y: -6) //
}
.padding(.horizontal,4)
.contentShape(Rectangle())
}
///
/// - 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)
.padding(.horizontal, 4)
.padding(.bottom, 2)
}
.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 }) {
Image(systemName: "plus")
.font(.system(size: 8, weight: .bold))
.foregroundColor(.black)
.frame(width: 58, height: 58)
.background(Color.white)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(style: StrokeStyle(
lineWidth: 2,
dash: [4, 4]
))
.foregroundColor(Color.themePrimary)
)
}
}
}
// 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(
ZStack {
RoundedRectangle(cornerRadius: 20)
.stroke(style: StrokeStyle(
lineWidth: 5,
lineCap: .round,
dash: [12, 8]
))
.foregroundColor(Color.themePrimary)
// Add plus icon in the center
Image(systemName: "plus")
.font(.system(size: 32, weight: .bold))
.foregroundColor(.black)
}
)
}
}
}
// 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>()
private static var cacheKeys = Set<String>()
static func setImage(_ image: UIImage, forKey key: String) {
shared.setObject(image, forKey: key as NSString)
cacheKeys.insert(key)
}
static func getImage(forKey key: String) -> UIImage? {
return shared.object(forKey: key as NSString)
}
static func clearCache() {
cacheKeys.forEach { key in
shared.removeObject(forKey: key as NSString)
}
cacheKeys.removeAll()
}
}
// MARK: -
init(media: MediaType, uploadManager: MediaUploadManager) {
self.media = media
self.uploadManager = uploadManager
//
let cacheKey = "\(media.id)" as NSString
if let cachedImage = ImageCache.shared.object(forKey: cacheKey) {
self._image = State(initialValue: cachedImage)
self._loadState = State(initialValue: .success(cachedImage))
}
}
// 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 {
// 1.
if let image = image {
ZStack(alignment: .bottomTrailing) {
loadedImageView(image)
}
// 100%
if isUploading && uploadProgress < 1.0 {
VStack {
Spacer()
ProgressView(value: uploadProgress, total: 1.0)
.progressViewStyle(LinearProgressViewStyle(tint: .white))
.frame(height: 2)
.padding(.horizontal, 4)
.padding(.bottom, 2)
}
}
} else {
// 2.
placeholderView
.onAppear {
loadImage()
}
}
// 3.
if case .failure(let error) = loadState, image == nil {
errorView(error: error)
}
}
.aspectRatio(1, contentMode: .fill)
.clipped()
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.themePrimary.opacity(0.3), lineWidth: 1)
)
}
//
private var placeholderView: some View {
Color.gray.opacity(0.1)
}
//
private func loadedImageView(_ image: UIImage) -> some View {
Image(uiImage: image)
.resizable()
.scaledToFill()
.transition(.opacity.animation(.easeInOut(duration: 0.2)))
}
///
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)
}
// MARK: -
///
private func loadImage() {
let cacheKey = "\(media.id)" as NSString
//
if let cachedImage = ImageCache.getImage(forKey: cacheKey as String) {
image = cachedImage
loadState = .success(cachedImage)
return
}
// 使
imageProcessingQueue.async {
do {
let imageToCache: UIImage
switch 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.setImage(imageToCache, forKey: cacheKey as String)
// UI
DispatchQueue.main.async {
withAnimation(.easeInOut(duration: 0.2)) {
image = imageToCache
loadState = .success(imageToCache)
}
}
} catch {
print("图片加载失败: \(error.localizedDescription)")
DispatchQueue.main.async {
loadState = .failure(error)
}
}
}
}
}
private func getVideoDuration(url: URL) -> String {
let asset = AVURLAsset(url: url)
let durationInSeconds = CMTimeGetSeconds(asset.duration)
guard durationInSeconds.isFinite else { return "0:00" }
let minutes = Int(durationInSeconds) / 60
let seconds = Int(durationInSeconds) % 60
return String(format: "%d:%02d", minutes, seconds)
}
// 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.hashValue)"
}
}
}
extension TimeInterval {
var formattedDuration: String {
let minutes = Int(self) / 60
let seconds = Int(self) % 60
return String(format: "%d:%02d", minutes, seconds)
}
}
// MARK: -
struct MediaUploadView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
MediaUploadView()
}
}
}