import SwiftUI import AVKit import WaterfallGrid // MARK: - API Response Models for BlindList struct BlindListMaterialResponse: Decodable { let code: Int let data: [BlindBoxItem] } struct BlindBoxItem: Identifiable, Decodable { let id: Int64 let boxCode: String let userId: Int64 let name: String let boxType: String let features: String? let resultFile: FileInfo? let status: String let workflowInstanceId: Int64? let videoGenerateTime: String? let createTime: String let coverFile: FileInfo? let description: String struct FileInfo: Decodable { let id: String let fileName: String let url: String let metadata: [String: String]? enum CodingKeys: String, CodingKey { case id case fileName = "file_name" case url case metadata } } enum CodingKeys: String, CodingKey { case id case boxCode = "box_code" case userId = "user_id" case name case boxType = "box_type" case features case resultFile = "result_file" case status case workflowInstanceId = "workflow_instance_id" case videoGenerateTime = "video_generate_time" case createTime = "create_time" case coverFile = "cover_file" case description } } enum BlindListMemoryMediaType: Equatable { case image(String) case video(url: String, previewUrl: String) var isVideo: Bool { if case .video = self { return true } return false } var url: String { switch self { case .image(let url): return url case .video(let url, _): return url } } } // MARK: - View Models struct BlindListFileInfo { let id: String let fileName: String let url: String } struct BlindListMemoryItem: Identifiable { let id: String let name: String let description: String let fileInfo: BlindListFileInfo let previewFileInfo: BlindListFileInfo var title: String { name } var subtitle: String { description } var mediaType: BlindListMemoryMediaType { // Determine media type based on file extension or other criteria // For now, default to image return .image(fileInfo.url) } var aspectRatio: CGFloat { 1.0 } } struct BlindListView: View { @Environment(\.dismiss) private var dismiss @State private var memories: [BlindListMemoryItem] = [] @State private var isLoading = false @State private var errorMessage: String? @State private var selectedMemory: BlindListMemoryItem? = nil let columns = [ GridItem(.flexible(), spacing: 1), GridItem(.flexible(), spacing: 1) ] var body: some View { NavigationView { ZStack { VStack(spacing: 0) { // 顶部导航栏 HStack { Button(action: { self.dismiss() }) { Image(systemName: "chevron.left") .foregroundColor(.themeTextMessageMain) .font(.system(size: 20)) } Spacer() Text("我的盲盒") .foregroundColor(.themeTextMessageMain) .font(Typography.font(for: .body, family: .quicksandBold)) Spacer() } .padding() .background(Color.themeTextWhiteSecondary) // 内容区域 ZStack { Color.themeTextWhiteSecondary.ignoresSafeArea() ScrollView { WaterfallGrid(memories) { memory in BlindListMemoryCard(memory: memory) .onTapGesture { withAnimation(.spring()) { selectedMemory = memory } } } .padding(.horizontal, 8) .padding(.vertical, 4) } } } // 全屏模态 if let memory = selectedMemory { BlindListFullScreenMediaView(memory: memory, isPresented: $selectedMemory) .transition(.opacity) .zIndex(1) } } } .navigationBarBackButtonHidden(true) .onAppear { fetchList() } } private func fetchList() { isLoading = true errorMessage = nil NetworkService.shared.get(path: "/blind_boxs/query", parameters: nil) { [self] (result: Result) in DispatchQueue.main.async { [self] in self.isLoading = false switch result { case .success(let response): print("✅ Successfully fetched \(response.data.count) blind box items") // Convert BlindBoxItem to BlindListMemoryItem self.memories = response.data .filter { $0.status == "Opened" } .map { item in BlindListMemoryItem( id: String(item.id), name: item.name, description: item.description, fileInfo: BlindListFileInfo( id: item.resultFile?.id ?? "", fileName: item.resultFile?.fileName ?? "", url: item.resultFile?.url ?? "" ), previewFileInfo: BlindListFileInfo( id: item.coverFile?.id ?? "", fileName: item.coverFile?.fileName ?? "", url: item.coverFile?.url ?? "" ) ) } case .failure(let error): self.errorMessage = error.localizedDescription print("❌ Failed to fetch blind box items: \(error.localizedDescription)") } } } } } struct BlindListFullScreenMediaView: View { let memory: BlindListMemoryItem @Binding var isPresented: BlindListMemoryItem? @State private var isVideoPlaying = false @State private var showControls = true @State private var controlsTimer: Timer? = nil @State private var imageAspectRatio: CGFloat = 1.0 @State private var isLoading = true private func loadAspectRatio(from url: URL) { guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil), let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any], let width = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat, let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat, height > 0 else { imageAspectRatio = 16/9 isLoading = false return } imageAspectRatio = width / height isLoading = false } var body: some View { ZStack { Color.black.ignoresSafeArea() ZStack { GeometryReader { geometry in switch memory.mediaType { case .image(let url): if let imageURL = URL(string: url) { AsyncImage(url: imageURL) { phase in switch phase { case .success(let image): GeometryReader { geometry in ZStack { Color.black image .resizable() .scaledToFit() .frame( width: min(geometry.size.width, geometry.size.height * imageAspectRatio), height: min(geometry.size.height, geometry.size.width / imageAspectRatio) ) } .frame(maxWidth: .infinity, maxHeight: .infinity) } .onAppear { if let uiImage = image.asUIImage() { let size = uiImage.size imageAspectRatio = size.width / size.height isLoading = false } } case .failure(_): Image(systemName: "exclamationmark.triangle") .foregroundColor(.red) case .empty: ProgressView() @unknown default: EmptyView() } } } case .video(_, let previewUrl): GeometryReader { geometry in ZStack { Color.clear BlindListVideoPlayer(url: memory.mediaType.url, isPlaying: $isVideoPlaying) .aspectRatio(imageAspectRatio, contentMode: .fit) .frame( width: min(geometry.size.width, geometry.size.height * imageAspectRatio), height: min(geometry.size.height, geometry.size.width / imageAspectRatio) ) .onAppear { if let previewUrl = URL(string: previewUrl) { loadAspectRatio(from: previewUrl) } isVideoPlaying = true } .onDisappear { isVideoPlaying = false } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } } } VStack { HStack { Button(action: { withAnimation(.spring()) { isPresented = nil } }) { Image(systemName: "chevron.left") .font(.system(size: 20, weight: .bold)) .foregroundColor(.white) .padding(12) .background(Circle().fill(Color.black.opacity(0.4))) } .padding(.leading, 16) .padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0) Spacer() } Spacer() } .zIndex(2) } .ignoresSafeArea() .statusBar(hidden: true) } .onTapGesture { if case .video = memory.mediaType { withAnimation(.easeInOut) { showControls.toggle() } } } .statusBar(hidden: true) .onAppear { UIApplication.shared.isIdleTimerDisabled = true } .onDisappear { UIApplication.shared.isIdleTimerDisabled = false controlsTimer?.invalidate() } } } struct BlindListVideoPlayer: UIViewControllerRepresentable { let url: String @Binding var isPlaying: Bool func makeUIViewController(context: Context) -> AVPlayerViewController { let controller = AVPlayerViewController() let player = AVPlayer(url: URL(string: url)!) controller.player = player controller.showsPlaybackControls = true controller.videoGravity = .resizeAspect controller.view.backgroundColor = .clear controller.view.isOpaque = false return controller } func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { if isPlaying { uiViewController.player?.play() } else { uiViewController.player?.pause() } } } struct BlindListMemoryCard: View { let memory: BlindListMemoryItem @State private var aspectRatio: CGFloat = 1.0 @State private var isLoading = true private func loadAspectRatio(from url: URL) { guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil), let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any], let width = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat, let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat, height > 0 else { aspectRatio = 16/9 isLoading = false return } aspectRatio = width / height isLoading = false } var body: some View { VStack(alignment: .leading, spacing: 8) { ZStack { Group { switch memory.mediaType { case .image(let url): if let url = URL(string: url) { AsyncImage(url: url) { phase in Group { if let image = phase.image { GeometryReader { geometry in ZStack { Color.black image .resizable() .scaledToFit() .frame( width: min(geometry.size.width, geometry.size.height * aspectRatio), height: min(geometry.size.height, geometry.size.width / aspectRatio) ) } .frame(maxWidth: .infinity, maxHeight: .infinity) } .aspectRatio(aspectRatio, contentMode: aspectRatio > 1 ? .fit : .fill) .onAppear { if let uiImage = image.asUIImage() { let size = uiImage.size aspectRatio = size.width / size.height isLoading = false } } } else if phase.error != nil { Color.gray.opacity(0.3) } else { ProgressView() } } } } case .video(_, let previewUrl): if let previewUrl = URL(string: previewUrl) { AsyncImage(url: previewUrl) { phase in Group { if let image = phase.image { image .resizable() .aspectRatio(contentMode: .fill) .onAppear { loadAspectRatio(from: previewUrl) } } else if phase.error != nil { Color.gray.opacity(0.3) } else { ProgressView() } } } } else { Color.gray.opacity(0.3) } } } .frame( width: (UIScreen.main.bounds.width / 2) - 24, height: (UIScreen.main.bounds.width / 2 - 24) / (isLoading ? 1 : aspectRatio) ) .clipped() .cornerRadius(12) if case .video = memory.mediaType { Image(systemName: "play.circle.fill") .font(.system(size: 40)) .foregroundColor(.white.opacity(0.9)) .shadow(radius: 3) } } VStack(alignment: .leading, spacing: 4) { Text(memory.title) .font(Typography.font(for: .body, family: .quicksandBold)) .foregroundColor(.themeTextMessageMain) .lineLimit(1) Text(memory.subtitle) .font(.system(size: 14)) .foregroundColor(.themeTextMessageMain) .lineLimit(2) } .padding(.horizontal, 2) .padding(.bottom, 8) } } }