import SwiftUI import AVKit // 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.url.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() Group { if isLoading { ProgressView() .scaleEffect(1.5) } else if let error = errorMessage { Text("Error: \(error)") .foregroundColor(.red) } else { ScrollView { LazyVGrid(columns: columns, spacing: 4) { ForEach(memories) { memory in MemoryCard(memory: memory) .padding(.horizontal, 2) .onTapGesture { withAnimation(.spring()) { selectedMemory = memory } } } } .padding(.top, 4) .padding(.horizontal, 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 player: AVPlayer? = nil var body: some View { ZStack { // Background Color.black.ignoresSafeArea() // Media content with back button overlay ZStack { // Media content switch memory.mediaType { case .image(let url): if let imageURL = URL(string: url) { AsyncImage(url: imageURL) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height) .edgesIgnoringSafeArea(.all) case .failure(_): Image(systemName: "exclamationmark.triangle") .foregroundColor(.red) case .empty: ProgressView() @unknown default: EmptyView() } } } case .video(let url, let previewUrl): if let videoURL = URL(string: url) { VideoPlayer(player: player) .onAppear { self.player = AVPlayer(url: videoURL) self.player?.play() self.isVideoPlaying = true } .onDisappear { self.player?.pause() self.player = nil } .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height) .onTapGesture { togglePlayPause() } } } // 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) } .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 // Video controls overlay (only for video) if case .video = memory.mediaType, showControls { VStack { Spacer() // Play/pause button Button(action: { togglePlayPause() }) { Image(systemName: isVideoPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 70)) .foregroundColor(.white.opacity(0.9)) .shadow(radius: 3) } .padding(.bottom, 30) } .transition(.opacity) .onAppear { resetControlsTimer() } .zIndex(3) // Highest z-index for controls } } .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) { self.player = AVPlayer(url: videoURL) self.player?.play() self.isVideoPlaying = true // Add observer for playback end NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: self.player?.currentItem, queue: .main ) { _ in self.player?.seek(to: .zero) { _ in self.player?.play() } } } } private func togglePlayPause() { if isVideoPlaying { pauseVideo() } else { playVideo() } withAnimation { showControls = true } resetControlsTimer() } private func playVideo() { player?.play() isVideoPlaying = true } private func pauseVideo() { player?.pause() isVideoPlaying = false } private func resetControlsTimer() { controlsTimer?.invalidate() controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in withAnimation(.easeInOut) { showControls = false } } } } struct VideoPlayer: UIViewRepresentable { let player: AVPlayer? func makeUIView(context: Context) -> UIView { let view = UIView() if let player = player { let playerLayer = AVPlayerLayer(player: player) playerLayer.frame = UIScreen.main.bounds playerLayer.videoGravity = .resizeAspectFill view.layer.addSublayer(playerLayer) } return view } func updateUIView(_ uiView: UIView, context: Context) { if let player = player, let playerLayer = uiView.layer.sublayers?.first as? AVPlayerLayer { playerLayer.player = player playerLayer.frame = UIScreen.main.bounds } } } struct MemoryCard: View { let memory: MemoryItem var body: some View { VStack(alignment: .leading, spacing: 16) { ZStack { // Media content Group { switch memory.mediaType { case .image(let url): if let url = URL(string: url) { AsyncImage(url: url) { phase in if let image = phase.image { image .resizable() .aspectRatio(contentMode: .fill) } 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 if let image = phase.image { image .resizable() .aspectRatio(contentMode: .fill) } 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) * (1/memory.aspectRatio)) .clipped() .cornerRadius(12) // Show play button for videos if case .video = memory.mediaType { Image(systemName: "play.circle.fill") .font(.system(size: 40)) .foregroundColor(.white.opacity(0.9)) .shadow(radius: 3) } } // Title and Subtitle VStack(alignment: .leading, spacing: 16) { 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) } } } #Preview { MemoriesView() }