import SwiftUI import AVKit import WaterfallGrid // MARK: - API Response Models struct MaterialResponse: Decodable { let code: Int let data: MaterialData struct MaterialData: Decodable { let items: [MemoryItem] let hasMore: Bool enum CodingKeys: String, CodingKey { case items case hasMore = "has_more" } } } struct MemoryItem: Identifiable, Decodable { let id: String let name: String? let description: String? let fileInfo: FileInfo let previewFileInfo: FileInfo var title: String { name ?? "Untitled" } var subtitle: String { description ?? "" } var mediaType: MemoryMediaType { let url = fileInfo.fileName.lowercased() if url.hasSuffix(".mp4") || url.hasSuffix(".mov") { return .video(url: fileInfo.url, previewUrl: previewFileInfo.url) } else { return .image(fileInfo.url) } } var aspectRatio: CGFloat { 1.0 } enum CodingKeys: String, CodingKey { case id, name, description case fileInfo = "file_info" case previewFileInfo = "preview_file_info" } } struct FileInfo: Decodable { let id: String let fileName: String let url: String enum CodingKeys: String, CodingKey { case id case fileName = "file_name" case url } } enum MemoryMediaType: Equatable { case image(String) case video(url: String, previewUrl: String) } struct MemoriesView: View { @Environment(\.dismiss) private var dismiss @State private var memories: [MemoryItem] = [] @State private var isLoading = false @State private var errorMessage: String? @State private var selectedMemory: MemoryItem? = nil let columns = [ GridItem(.flexible(), spacing: 1), GridItem(.flexible(), spacing: 1) ] var body: some View { NavigationView { ZStack { VStack(spacing: 0) { // Top navigation bar HStack { Button(action: { self.dismiss() }) { Image(systemName: "chevron.left") .foregroundColor(.themeTextMessageMain) .font(.system(size: 20)) } Spacer() Text("My Memories") .foregroundColor(.themeTextMessageMain) .font(Typography.font(for: .body, family: .quicksandBold)) Spacer() } .padding() .background(Color.themeTextWhiteSecondary) // Content area ZStack { Color.themeTextWhiteSecondary.ignoresSafeArea() ScrollView { WaterfallGrid(memories) { memory in MemoryCard(memory: memory) .onTapGesture { withAnimation(.spring()) { selectedMemory = memory } } } .padding(.horizontal, 8) .padding(.vertical, 4) } } } // Full Screen Modal if let memory = selectedMemory { FullScreenMediaView(memory: memory, isPresented: $selectedMemory) .transition(.opacity) .zIndex(1) } } } .navigationBarBackButtonHidden(true) .onAppear { fetchMemories() } } private func fetchMemories() { isLoading = true errorMessage = nil let parameters: [String: Any] = ["page": 0] NetworkService.shared.get(path: "/material/list", parameters: parameters) { [self] (result: Result) in DispatchQueue.main.async { [self] in self.isLoading = false switch result { case .success(let response): print("✅ Successfully fetched \(response.data.items) memory items") response.data.items.forEach { item in print("📝 Item ID: \(item.id), Title: \(item.name ?? "Untitled"), URL: \(item)") } self.memories = response.data.items case .failure(let error): self.errorMessage = error.localizedDescription print("❌ Failed to fetch memories: \(error.localizedDescription)") } } } } } struct FullScreenMediaView: View { let memory: MemoryItem @Binding var isPresented: MemoryItem? @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 // Default to 16:9 if we can't determine the aspect ratio isLoading = false return } imageAspectRatio = width / height isLoading = false } var body: some View { ZStack { // Background Color.black.ignoresSafeArea() // Media content with back button overlay ZStack { // Media content 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 VideoPlayer(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) } } } // Back button - Always visible at the top-left of the device screen VStack { HStack { Button(action: { withAnimation(.spring()) { isPresented = nil // pauseVideo() } }) { 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) // Higher z-index to keep it above media content } .ignoresSafeArea() .statusBar(hidden: true) } .onTapGesture { if case .video = memory.mediaType { withAnimation(.easeInOut) { // showControls.toggle() } // if showControls { // resetControlsTimer() // } } } .statusBar(hidden: true) .onAppear { UIApplication.shared.isIdleTimerDisabled = true if case .video = memory.mediaType { // setupVideoPlayer() } } .onDisappear { UIApplication.shared.isIdleTimerDisabled = false controlsTimer?.invalidate() // pauseVideo() } } // private func setupVideoPlayer() { // if case .video(let url, _) = memory.mediaType, let videoURL = URL(string: url) { // // No need to set up player here // } // } // private func togglePlayPause() { // if isVideoPlaying { // pauseVideo() // } else { // playVideo() // } // withAnimation { // showControls = true // } // resetControlsTimer() // } // private func playVideo() { // // No need to play video here // } // private func pauseVideo() { // // No need to pause video here // } // private func resetControlsTimer() { // controlsTimer?.invalidate() // controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in // withAnimation(.easeInOut) { // showControls = false // } // } // } } struct VideoPlayer: 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 // Make the background transparent 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 MemoryCard: View { let memory: MemoryItem @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 // Default to 16:9 if we can't determine the aspect ratio 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) } } } // Add this extension to get UIImage from Image extension View { func asUIImage() -> UIImage? { let controller = UIHostingController(rootView: self) let view = controller.view let targetSize = controller.view.intrinsicContentSize view?.bounds = CGRect(origin: .zero, size: targetSize) view?.backgroundColor = .clear let renderer = UIGraphicsImageRenderer(size: targetSize) return renderer.image { _ in view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true) } } } // Add this extension to MemoryMediaType to get the URL private extension MemoryMediaType { 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 } } } #Preview { MemoriesView() }