feat: memories

This commit is contained in:
jinyaqiu 2025-08-29 21:27:48 +08:00
parent 0c75e1b446
commit 84cc5d207f

View File

@ -65,6 +65,7 @@ struct MemoriesView: View {
@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),
@ -73,51 +74,64 @@ struct MemoriesView: View {
var body: some View {
NavigationView {
VStack(spacing: 0) {
//
HStack {
Button(action: {
//
self.dismiss()
}) {
Image(systemName: "chevron.left")
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(.system(size: 20))
.font(Typography.font(for: .body, family: .quicksandBold))
Spacer()
}
Spacer()
Text("My Memories")
.foregroundColor(.themeTextMessageMain)
.font(Typography.font(for: .body, family: .quicksandBold))
Spacer()
}
.padding()
.background(Color.themeTextWhiteSecondary)
.padding()
.background(Color.themeTextWhiteSecondary)
//
ZStack {
Color.themeTextWhiteSecondary.ignoresSafeArea()
// 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)
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)
}
.padding(.top, 4)
.padding(.horizontal, 4)
}
}
}
}
// Full Screen Modal
if let memory = selectedMemory {
FullScreenMediaView(memory: memory, isPresented: $selectedMemory)
.transition(.opacity)
.zIndex(1)
}
}
}
.navigationBarBackButtonHidden(true)
@ -152,17 +166,221 @@ struct MemoriesView: View {
}
}
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: 12) {
VStack(alignment: .leading, spacing: 16) {
ZStack {
// Media content
Group {
switch memory.mediaType {
case .image(let urlString):
if let url = URL(string: urlString) {
case .image(let url):
if let url = URL(string: url) {
AsyncImage(url: url) { phase in
if let image = phase.image {
image
@ -176,8 +394,7 @@ struct MemoryCard: View {
}
}
case .video(let url, let previewUrl):
// Use preview image for video
case .video(_, let previewUrl):
if let previewUrl = URL(string: previewUrl) {
AsyncImage(url: previewUrl) { phase in
if let image = phase.image {
@ -210,7 +427,7 @@ struct MemoryCard: View {
}
// Title and Subtitle
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 16) {
Text(memory.title)
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(.themeTextMessageMain)
@ -219,6 +436,7 @@ struct MemoryCard: View {
Text(memory.subtitle)
.font(.system(size: 14))
.foregroundColor(.themeTextMessageMain)
.lineLimit(2)
}
.padding(.horizontal, 2)
.padding(.bottom, 8)
@ -226,13 +444,6 @@ struct MemoryCard: View {
}
}
// Helper extension to pause video
private extension AVPlayer {
func pause() {
self.pause()
}
}
#Preview {
MemoriesView()
}