wake-ios/wake/View/Memories/MemoriesView.swift

517 lines
20 KiB
Swift

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 {
// Removed dismiss environment; use Router.shared.pop() for back navigation
@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 {
ZStack {
VStack(spacing: 0) {
// Top navigation bar
HStack {
Button(action: {
Router.shared.pop()
}) {
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<MaterialResponse, NetworkError>) 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()
}