diff --git a/Tiny/App/tinyApp.swift b/Tiny/App/tinyApp.swift index 550ddd8..273c153 100644 --- a/Tiny/App/tinyApp.swift +++ b/Tiny/App/tinyApp.swift @@ -25,7 +25,8 @@ struct TinyApp: App { // Define the container configuration var sharedModelContainer: ModelContainer = { let schema = Schema([ - SavedHeartbeat.self + SavedHeartbeat.self, + SavedMoment.self ]) let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) diff --git a/Tiny/Core/Models/SavedHeartbeatModel.swift b/Tiny/Core/Models/SavedHeartbeatModel.swift index a77cae0..a7d1135 100644 --- a/Tiny/Core/Models/SavedHeartbeatModel.swift +++ b/Tiny/Core/Models/SavedHeartbeatModel.swift @@ -14,6 +14,7 @@ class SavedHeartbeat { @Attribute(.unique) var id: UUID var filePath: String // Local file path var timestamp: Date + var displayName: String? // Custom name for the recording // Firebase fields var firebaseId: String? // Document ID in Firestore (for metadata) @@ -26,6 +27,7 @@ class SavedHeartbeat { init(filePath: String, timestamp: Date = Date(), + displayName: String? = nil, motherUserId: String? = nil, roomCode: String? = nil, isShared: Bool = true, // Changed default from false to true @@ -36,6 +38,7 @@ class SavedHeartbeat { self.id = UUID() self.filePath = filePath self.timestamp = timestamp + self.displayName = displayName self.motherUserId = motherUserId self.roomCode = roomCode self.isShared = isShared @@ -55,6 +58,9 @@ extension SavedHeartbeat { "isSyncedToCloud": isSyncedToCloud ] + if let displayName = displayName { + dict["displayName"] = displayName + } if let motherUserId = motherUserId { dict["motherUserId"] = motherUserId } @@ -79,6 +85,7 @@ extension SavedHeartbeat { return SavedHeartbeat( filePath: "", // Will be set after downloading timestamp: timestamp, + displayName: data["displayName"] as? String, motherUserId: data["motherUserId"] as? String, roomCode: data["roomCode"] as? String, isShared: data["isShared"] as? Bool ?? false, diff --git a/Tiny/Core/Models/SavedMomentModel.swift b/Tiny/Core/Models/SavedMomentModel.swift new file mode 100644 index 0000000..41240fd --- /dev/null +++ b/Tiny/Core/Models/SavedMomentModel.swift @@ -0,0 +1,46 @@ +// +// SavedMomentModel.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import SwiftData +import Foundation + +@Model +class SavedMoment { + @Attribute(.unique) var id: UUID + var filePath: String // Local file path for the image + var timestamp: Date + var pregnancyWeeks: Int? + + // Firebase fields + var firebaseId: String? + var motherUserId: String? + var roomCode: String? + var isShared: Bool + var firebaseStorageURL: String? + var isSyncedToCloud: Bool + + init(filePath: String, + timestamp: Date = Date(), + pregnancyWeeks: Int? = nil, + firebaseId: String? = nil, + motherUserId: String? = nil, + roomCode: String? = nil, + isShared: Bool = true, + firebaseStorageURL: String? = nil, + isSyncedToCloud: Bool = false) { + self.id = UUID() + self.filePath = filePath + self.timestamp = timestamp + self.pregnancyWeeks = pregnancyWeeks + self.firebaseId = firebaseId + self.motherUserId = motherUserId + self.roomCode = roomCode + self.isShared = isShared + self.firebaseStorageURL = firebaseStorageURL + self.isSyncedToCloud = isSyncedToCloud + } +} diff --git a/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift index b1d0929..d71949f 100644 --- a/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift +++ b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift @@ -22,9 +22,22 @@ struct Recording: Identifiable, Equatable { let id = UUID() let fileURL: URL let createdAt: Date + var displayName: String? // Custom name from SwiftData var isPlaying: Bool = false } +struct Moment: Identifiable, Equatable { + let id: UUID + let fileURL: URL + let createdAt: Date + + init(id: UUID = UUID(), fileURL: URL, createdAt: Date) { + self.id = id + self.fileURL = fileURL + self.createdAt = createdAt + } +} + struct HeartbeatData { let timestamp: Date let bpm: Double @@ -73,6 +86,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { @Published var isRecording = false @Published var lastRecording: Recording? @Published var savedRecordings: [Recording] = [] + @Published var savedMoments: [Moment] = [] @Published var isPlayingPlayback = false @Published var amplitudeVal: Float = 0.0 @Published var blinkAmplitude: Float = 0.0 @@ -109,7 +123,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { guard let modelContext = modelContext else { return } do { let results = try modelContext.fetch(FetchDescriptor()) - let documentsURL = getDocumentsDirectory() + _ = getDocumentsDirectory() DispatchQueue.main.async { // Map the REAL timestamp from SwiftData @@ -124,15 +138,42 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } print("✅ Found recording: \(fileURL.lastPathComponent)") + if let displayName = savedItem.displayName { + print(" Custom name: \(displayName)") + } return Recording( fileURL: fileURL, - createdAt: savedItem.timestamp + createdAt: savedItem.timestamp, + displayName: savedItem.displayName ) } print("✅ Loaded \(self.savedRecordings.count) recordings from SwiftData") } + // Load Moments + let momentResults = try modelContext.fetch(FetchDescriptor()) + DispatchQueue.main.async { + self.savedMoments = momentResults.compactMap { savedItem in + // Fix: Use filename only to reconstruct path, as absolute paths change on container recreation + let storedPath = savedItem.filePath + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let fileURL = self.getDocumentsDirectory().appendingPathComponent(fileName) + + if !FileManager.default.fileExists(atPath: fileURL.path) { + print("⚠️ Moment file missing: \(fileURL.path)") + return nil + } + + return Moment( + id: savedItem.id, + fileURL: fileURL, + createdAt: savedItem.timestamp + ) + } + print("✅ Loaded \(self.savedMoments.count) moments from SwiftData") + } + // Sync from cloud if we have the necessary info if let roomCode = currentRoomCode, let syncManager = syncManager { Task { @MainActor in @@ -141,14 +182,21 @@ class HeartbeatSoundManager: NSObject, ObservableObject { let isMother = currentUserRole == .mother // Fetch heartbeats from cloud - let syncedHeartbeats = try await syncManager.syncHeartbeatsFromCloud( + _ = try await syncManager.syncHeartbeatsFromCloud( roomCode: roomCode, modelContext: modelContext, isMother: isMother ) + // Fetch moments from cloud + _ = try await syncManager.syncMomentsFromCloud( + roomCode: roomCode, + modelContext: modelContext + ) + // Reload from SwiftData after sync let updatedResults = try modelContext.fetch(FetchDescriptor()) + let updatedMoments = try modelContext.fetch(FetchDescriptor()) // Show all heartbeats for both mothers and fathers (all are shared by default) self.savedRecordings = updatedResults.compactMap { savedItem in @@ -163,11 +211,31 @@ class HeartbeatSoundManager: NSObject, ObservableObject { return Recording( fileURL: fileURL, - createdAt: savedItem.timestamp + createdAt: savedItem.timestamp, + displayName: savedItem.displayName ) } print("✅ Reloaded \(self.savedRecordings.count) recordings after sync (isMother: \(isMother))") + // Reload moments with SavedMoment IDs to prevent duplicates + self.savedMoments = updatedMoments.compactMap { savedItem in + // Fix: Use filename only + let storedPath = savedItem.filePath + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let fileURL = self.getDocumentsDirectory().appendingPathComponent(fileName) + + if !FileManager.default.fileExists(atPath: fileURL.path) { + return nil + } + + return Moment( + id: savedItem.id, + fileURL: fileURL, + createdAt: savedItem.timestamp + ) + } + print("✅ Reloaded \(self.savedMoments.count) moments after sync") + // Force UI update self.objectWillChange.send() } catch { @@ -674,11 +742,22 @@ class HeartbeatSoundManager: NSObject, ObservableObject { guard let modelContext = modelContext else { return } - // Create entry with isShared = true by default + // Calculate current pregnancy week + let pregnancyWeek: Int? = { + guard let pregnancyStartDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + return nil + } + let calendar = Calendar.current + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: recording.createdAt).weekOfYear ?? 0 + return weeksSinceStart + }() + + // Create entry with isShared = true by default and pregnancy week let entry = SavedHeartbeat( filePath: recording.fileURL.path, timestamp: recording.createdAt, - isShared: true // Auto-share all heartbeats + isShared: true, // Auto-share all heartbeats + pregnancyWeeks: pregnancyWeek ) modelContext.insert(entry) @@ -719,5 +798,221 @@ class HeartbeatSoundManager: NSObject, ObservableObject { print("❌ SwiftData save failed: \(error)") } } + + func deleteRecording(_ recording: Recording) { + guard let modelContext = modelContext else { return } + + print("🗑️ Deleting recording: \(recording.fileURL.lastPathComponent)") + + // Remove from savedRecordings array + if let index = savedRecordings.firstIndex(where: { $0.id == recording.id }) { + savedRecordings.remove(at: index) + print("✅ Removed from savedRecordings array. New count: \(savedRecordings.count)") + // Force UI update + objectWillChange.send() + } + + // Remove from SwiftData + do { + let filePath = recording.fileURL.path + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.filePath == filePath } + ) + let results = try modelContext.fetch(descriptor) + + for entry in results { + modelContext.delete(entry) + } + + try modelContext.save() + print("✅ Deleted from SwiftData") + + // Delete the audio file from disk + let fileManager = FileManager.default + if fileManager.fileExists(atPath: filePath) { + try fileManager.removeItem(atPath: filePath) + print("✅ Deleted audio file from disk") + } + + // Clear lastRecording if it's the one being deleted + if lastRecording?.id == recording.id { + lastRecording = nil + } + + // Delete from Firebase if it was synced + if let firebaseId = results.first?.firebaseId, + let syncManager = syncManager, + let entry = results.first { + Task { @MainActor in + do { + try await syncManager.deleteHeartbeat(entry) + print("✅ Deleted from Firebase") + } catch { + print("❌ Firebase deletion failed: \(error.localizedDescription)") + } + } + } + + } catch { + print("❌ Delete failed: \(error)") + } + } + + // MARK: - Moment Management + + func saveMoment(image: UIImage) { + guard let modelContext = modelContext else { return } + + // Save image to disk + guard let data = image.jpegData(compressionQuality: 0.8) else { return } + let timestamp = Date() + let filename = "moment-\(timestamp.timeIntervalSince1970).jpg" + let fileURL = getDocumentsDirectory().appendingPathComponent(filename) + + do { + try data.write(to: fileURL) + + // Calculate pregnancy week + let pregnancyWeek: Int? = { + guard let pregnancyStartDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + return nil + } + let calendar = Calendar.current + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: Date()).weekOfYear ?? 0 + return weeksSinceStart + }() + + // Save to SwiftData + // Store just the filename (relative path) to avoid absolute path issues + let savedMoment = SavedMoment( + filePath: filename, + timestamp: timestamp, + pregnancyWeeks: pregnancyWeek + ) + + modelContext.insert(savedMoment) + try modelContext.save() + + print("✅ Saved moment to SwiftData") + print(" File: \(filename)") + print(" Timestamp: \(timestamp)") + print(" Pregnancy Week: \(pregnancyWeek ?? -1)") + + // Update local array + let moment = Moment(id: savedMoment.id, fileURL: fileURL, createdAt: savedMoment.timestamp) + DispatchQueue.main.async { + self.savedMoments.append(moment) + self.objectWillChange.send() + } + + print("✅ Added moment to local array (count: \(savedMoments.count + 1))") + + // Upload to Firebase if possible + print("🔍 Checking Firebase upload requirements:") + print(" currentUserId: \(currentUserId ?? "nil")") + print(" currentRoomCode: \(currentRoomCode ?? "nil")") + print(" syncManager: \(syncManager != nil ? "available" : "nil")") + + if let userId = currentUserId, let roomCode = currentRoomCode, let syncManager = syncManager { + Task { @MainActor in + do { + print("📤 Uploading moment to Firebase...") + try await syncManager.uploadMoment(savedMoment, motherUserId: userId, roomCode: roomCode) + print("✅ Uploaded moment to Firebase") + print(" Storage URL: \(savedMoment.firebaseStorageURL ?? "not set")") + print(" Firebase ID: \(savedMoment.firebaseId ?? "not set")") + } catch { + print("❌ Failed to upload moment: \(error)") + } + } + } else { + print("⚠️ Skipping Firebase upload - missing required data") + if currentUserId == nil { print(" - User ID is nil") } + if currentRoomCode == nil { print(" - Room Code is nil") } + if syncManager == nil { print(" - Sync Manager is nil") } + } + + } catch { + print("❌ Failed to save moment: \(error)") + } + } + + func deleteMoment(_ moment: Moment) { + guard let modelContext = modelContext else { return } + + print("🗑️ Deleting moment with ID: \(moment.id)") + + // Remove from savedMoments array + if let index = savedMoments.firstIndex(where: { $0.id == moment.id }) { + savedMoments.remove(at: index) + print("✅ Removed from savedMoments array. New count: \(savedMoments.count)") + objectWillChange.send() + } + + // Remove from SwiftData using the Moment's ID (which is the SavedMoment's ID) + do { + // Capture the ID first for use in the Predicate + let momentId = moment.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == momentId } + ) + let results = try modelContext.fetch(descriptor) + + guard let entry = results.first else { + print("⚠️ No SavedMoment found with ID: \(moment.id)") + return + } + + let filePath = entry.filePath + let fileName = URL(fileURLWithPath: filePath).lastPathComponent + let fullPath = getDocumentsDirectory().appendingPathComponent(fileName).path + + // Delete from Firebase FIRST (before deleting from SwiftData) + if let firebaseId = entry.firebaseId, let syncManager = syncManager { + Task { @MainActor in + do { + print("🔥 Deleting moment from Firebase...") + try await syncManager.deleteMoment(entry) + print("✅ Deleted moment from Firebase") + print(" Firebase ID: \(firebaseId)") + + // After Firebase deletion succeeds, delete locally + self.deleteLocalMoment(entry: entry, fullPath: fullPath, modelContext: modelContext) + } catch { + print("❌ Failed to delete moment from Firebase: \(error)") + // Still delete locally even if Firebase delete fails + self.deleteLocalMoment(entry: entry, fullPath: fullPath, modelContext: modelContext) + } + } + } else { + // No Firebase sync, just delete locally + print("ℹ️ No Firebase ID, deleting locally only") + deleteLocalMoment(entry: entry, fullPath: fullPath, modelContext: modelContext) + } + + } catch { + print("❌ Delete moment failed: \(error)") + } + } + + private func deleteLocalMoment(entry: SavedMoment, fullPath: String, modelContext: ModelContext) { + do { + // Delete from SwiftData + modelContext.delete(entry) + try modelContext.save() + print("✅ Deleted moment from SwiftData") + + // Delete file from disk + let fileManager = FileManager.default + if fileManager.fileExists(atPath: fullPath) { + try fileManager.removeItem(atPath: fullPath) + print("✅ Deleted moment file from disk: \(fullPath)") + } else { + print("ℹ️ File not found on disk: \(fullPath)") + } + } catch { + print("❌ Failed to delete local moment: \(error)") + } + } } // swiftlint:enable type_body_length diff --git a/Tiny/Core/Services/Storage/FirebaseStorageService.swift b/Tiny/Core/Services/Storage/FirebaseStorageService.swift index 7e8237f..3b3818e 100644 --- a/Tiny/Core/Services/Storage/FirebaseStorageService.swift +++ b/Tiny/Core/Services/Storage/FirebaseStorageService.swift @@ -71,10 +71,10 @@ class FirebaseStorageService: ObservableObject { heartbeatId: String, timestamp: Date ) async throws -> URL { - guard let url = URL(string: downloadURL) else { + guard URL(string: downloadURL) != nil else { throw StorageError.invalidURL } - + // Create local file path with timestamp-based name (matching original format) let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let timeInterval = timestamp.timeIntervalSince1970 @@ -109,6 +109,65 @@ class FirebaseStorageService: ObservableObject { try await storageRef.delete() } + // MARK: - Upload Moment Image + + func uploadMomentImage( + localFileURL: URL, + motherUserId: String, + momentId: String + ) async throws -> String { + let storageRef = storage.reference() + let momentRef = storageRef.child("moments/\(motherUserId)/\(momentId).jpg") + + let data = try Data(contentsOf: localFileURL) + + let metadata = StorageMetadata() + metadata.contentType = "image/jpeg" + metadata.customMetadata = [ + "motherUserId": motherUserId, + "momentId": momentId, + "uploadedAt": ISO8601DateFormatter().string(from: Date()) + ] + + _ = try await momentRef.putDataAsync(data, metadata: metadata) + let downloadURL = try await momentRef.downloadURL() + + return downloadURL.absoluteString + } + + // MARK: - Download Moment Image + + func downloadMomentImage( + downloadURL: String, + momentId: String, + timestamp: Date + ) async throws -> URL { + guard URL(string: downloadURL) != nil else { + throw StorageError.invalidURL + } + + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let timeInterval = timestamp.timeIntervalSince1970 + let fileName = "moment-\(timeInterval).jpg" + let localURL = documentsPath.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: localURL.path) { + return localURL + } + + let storageRef = storage.reference(forURL: downloadURL) + _ = try await storageRef.writeAsync(toFile: localURL) + + return localURL + } + + // MARK: - Delete Moment Image + + func deleteMomentImage(downloadURL: String) async throws { + let storageRef = storage.reference(forURL: downloadURL) + try await storageRef.delete() + } + // MARK: - Get Shared Heartbeats for Room /// Lists all heartbeats shared in a room diff --git a/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift b/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift index 40905d1..bb0b8da 100644 --- a/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift +++ b/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift @@ -10,6 +10,7 @@ import FirebaseFirestore import SwiftData internal import Combine +// swiftlint:disable type_body_length @MainActor class HeartbeatSyncManager: ObservableObject { private let dbf = Firestore.firestore() @@ -56,7 +57,7 @@ class HeartbeatSyncManager: ObservableObject { // Keep the isShared value from the heartbeat (should be true by default) // 3. Save metadata to Firestore - let metadata: [String: Any] = [ + var metadata: [String: Any] = [ "heartbeatId": heartbeatId, "motherUserId": motherUserId, "roomCode": roomCode, @@ -67,6 +68,10 @@ class HeartbeatSyncManager: ObservableObject { "createdAt": Timestamp(date: Date()) ] + if let displayName = heartbeat.displayName { + metadata["displayName"] = displayName + } + print("💾 Saving metadata to Firestore...") print(" Metadata: \(metadata)") @@ -95,6 +100,28 @@ class HeartbeatSyncManager: ObservableObject { heartbeat.isShared = true } + // MARK: - Update Heartbeat Name + + /// Updates the display name of a heartbeat in Firestore + func updateHeartbeatName(_ heartbeat: SavedHeartbeat, newName: String) async throws { + guard let firebaseId = heartbeat.firebaseId else { + throw SyncError.notSynced + } + + print("📝 Updating heartbeat name in Firestore...") + print(" ID: \(firebaseId)") + print(" New Name: \(newName)") + + // Update Firestore + try await dbf.collection("heartbeats").document(firebaseId).updateData([ + "displayName": newName + ]) + + // Update local model + heartbeat.displayName = newName + print("✅ Heartbeat name updated in Firestore") + } + // MARK: - Unshare Heartbeat /// Marks a heartbeat as not shared @@ -154,7 +181,8 @@ class HeartbeatSyncManager: ObservableObject { firebaseStorageURL: data["firebaseStorageURL"] as? String ?? "", timestamp: (data["timestamp"] as? Timestamp)?.dateValue() ?? Date(), isShared: isShared, - pregnancyWeeks: data["pregnancyWeeks"] as? Int + pregnancyWeeks: data["pregnancyWeeks"] as? Int, + displayName: data["displayName"] as? String ) print(" ✅ Including heartbeat: \(metadata.id)") @@ -190,7 +218,8 @@ class HeartbeatSyncManager: ObservableObject { firebaseStorageURL: data["firebaseStorageURL"] as? String ?? "", timestamp: (data["timestamp"] as? Timestamp)?.dateValue() ?? Date(), isShared: data["isShared"] as? Bool ?? false, - pregnancyWeeks: data["pregnancyWeeks"] as? Int + pregnancyWeeks: data["pregnancyWeeks"] as? Int, + displayName: data["displayName"] as? String ) } @@ -293,6 +322,11 @@ class HeartbeatSyncManager: ObservableObject { firebaseId: metadata.id ) + // Set display name if available + if let displayName = metadata.displayName { + heartbeat.displayName = displayName + } + modelContext.insert(heartbeat) syncedHeartbeats.append(heartbeat) print(" ✅ Saved heartbeat to SwiftData: \(metadata.id)") @@ -307,7 +341,171 @@ class HeartbeatSyncManager: ObservableObject { return syncedHeartbeats } + + // MARK: - Moment Sync + + func uploadMoment( + _ moment: SavedMoment, + motherUserId: String, + roomCode: String + ) async throws { + isSyncing = true + defer { isSyncing = false } + + // 1. Reconstruct full path from filename (since we store relative paths) + let fileName = URL(fileURLWithPath: moment.filePath).lastPathComponent + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let localURL = documentsPath.appendingPathComponent(fileName) + let momentId = moment.id.uuidString + + print("📤 Uploading moment...") + print(" Local file: \(localURL.path)") + + let downloadURL = try await storageService.uploadMomentImage( + localFileURL: localURL, + motherUserId: motherUserId, + momentId: momentId + ) + + // 2. Update local model + moment.firebaseStorageURL = downloadURL + moment.isSyncedToCloud = true + moment.motherUserId = motherUserId + moment.roomCode = roomCode + + // 3. Save metadata to Firestore + let metadata: [String: Any] = [ + "momentId": momentId, + "motherUserId": motherUserId, + "roomCode": roomCode, + "firebaseStorageURL": downloadURL, + "timestamp": Timestamp(date: moment.timestamp), + "isShared": moment.isShared, + "pregnancyWeeks": moment.pregnancyWeeks ?? 0, + "createdAt": Timestamp(date: Date()) + ] + + let docRef = try await dbf.collection("moments").addDocument(data: metadata) + moment.firebaseId = docRef.documentID + + print("✅ Moment metadata saved to Firestore") + } + + func deleteMoment(_ moment: SavedMoment) async throws { + // Delete from Storage if synced + if let storageURL = moment.firebaseStorageURL { + try await storageService.deleteMomentImage(downloadURL: storageURL) + } + + // Delete from Firestore + if let firebaseId = moment.firebaseId { + try await dbf.collection("moments").document(firebaseId).delete() + } + } + + func fetchAllMomentsForRoom(roomCode: String) async throws -> [MomentMetadata] { + print("🔍 Fetching ALL moments for room code: '\(roomCode)'") + + let snapshot = try await dbf.collection("moments") + .whereField("roomCode", isEqualTo: roomCode) + .getDocuments() + + let moments = snapshot.documents.compactMap { doc -> MomentMetadata? in + let data = doc.data() + + return MomentMetadata( + id: doc.documentID, + momentId: data["momentId"] as? String ?? "", + motherUserId: data["motherUserId"] as? String ?? "", + roomCode: data["roomCode"] as? String ?? "", + firebaseStorageURL: data["firebaseStorageURL"] as? String ?? "", + timestamp: (data["timestamp"] as? Timestamp)?.dateValue() ?? Date(), + isShared: data["isShared"] as? Bool ?? false, + pregnancyWeeks: data["pregnancyWeeks"] as? Int + ) + } + + return moments.sorted { $0.timestamp > $1.timestamp } + } + + func downloadMoment(metadata: MomentMetadata) async throws -> URL { + return try await storageService.downloadMomentImage( + downloadURL: metadata.firebaseStorageURL, + momentId: metadata.momentId, + timestamp: metadata.timestamp + ) + } + + func syncMomentsFromCloud( + roomCode: String, + modelContext: ModelContext + ) async throws -> [SavedMoment] { + print("🔄 Syncing moments from cloud...") + + let metadataList = try await fetchAllMomentsForRoom(roomCode: roomCode) + var syncedMoments: [SavedMoment] = [] + + for metadata in metadataList { + // Check if we already have this moment locally + let allMoments = try modelContext.fetch(FetchDescriptor()) + let existingMoment = allMoments.first { $0.firebaseId == metadata.id } + + if let existing = existingMoment { + // Fix: Check existence using filename only + let storedPath = existing.filePath + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let localURL = documentsPath.appendingPathComponent(fileName) + + if !FileManager.default.fileExists(atPath: localURL.path) { + // Re-download if file is missing + do { + let downloadedURL = try await downloadMoment(metadata: metadata) + // Update to relative path (filename) + existing.filePath = downloadedURL.lastPathComponent + } catch { + print("❌ Re-download moment failed: \(error)") + } + } else { + // If file exists but path was absolute, update to relative for consistency + if existing.filePath != fileName { + existing.filePath = fileName + } + } + syncedMoments.append(existing) + } else { + // Download new moment + do { + let localURL = try await downloadMoment(metadata: metadata) + + let moment = SavedMoment( + filePath: localURL.lastPathComponent, // Store relative path + timestamp: metadata.timestamp, + pregnancyWeeks: metadata.pregnancyWeeks, + firebaseId: metadata.id, + motherUserId: metadata.motherUserId, + roomCode: metadata.roomCode, + isShared: metadata.isShared, + firebaseStorageURL: metadata.firebaseStorageURL, + isSyncedToCloud: true + ) + + modelContext.insert(moment) + syncedMoments.append(moment) + print(" ✅ Saved moment to SwiftData: \(metadata.id)") + } catch { + print(" ❌ Failed to download moment \(metadata.id): \(error)") + } + } + } + + try modelContext.save() + print("✅ Moment sync complete: \(syncedMoments.count) moments") + + return syncedMoments + } } +// swiftlint:enable type_body_length // MARK: - Supporting Types @@ -320,6 +518,18 @@ struct HeartbeatMetadata: Identifiable { let timestamp: Date let isShared: Bool let pregnancyWeeks: Int? + let displayName: String? +} + +struct MomentMetadata: Identifiable { + let id: String + let momentId: String + let motherUserId: String + let roomCode: String + let firebaseStorageURL: String + let timestamp: Date + let isShared: Bool + let pregnancyWeeks: Int? } enum SyncError: LocalizedError { @@ -330,11 +540,11 @@ enum SyncError: LocalizedError { var errorDescription: String? { switch self { case .notSynced: - return "Heartbeat has not been synced to cloud yet" + return "Item has not been synced to cloud yet" case .uploadFailed: - return "Failed to upload heartbeat" + return "Failed to upload item" case .downloadFailed: - return "Failed to download heartbeat" + return "Failed to download item" } } } diff --git a/Tiny/Core/Views/ImagePicker.swift b/Tiny/Core/Views/ImagePicker.swift new file mode 100644 index 0000000..5421de0 --- /dev/null +++ b/Tiny/Core/Views/ImagePicker.swift @@ -0,0 +1,48 @@ +// +// ImagePicker.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import SwiftUI +import UIKit + +struct ImagePicker: UIViewControllerRepresentable { + @Binding var image: UIImage? + @Environment(\.presentationMode) private var presentationMode + var sourceType: UIImagePickerController.SourceType = .photoLibrary + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = sourceType + picker.delegate = context.coordinator + picker.allowsEditing = false + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let uiImage = info[.originalImage] as? UIImage { + parent.image = uiImage + } + parent.presentationMode.wrappedValue.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.presentationMode.wrappedValue.dismiss() + } + } +} diff --git a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift index 058b4c9..f68fc2e 100644 --- a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift +++ b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift @@ -10,7 +10,9 @@ import SwiftData internal import Combine class HeartbeatMainViewModel: ObservableObject { - @Published var currentPage = 0 // 0 = Timeline (left), 1 = Orb (right) + @Published var currentPage: Int = 0 + @Published var allowTabViewSwipe: Bool = true + @Published var selectedRecording: Recording? let heartbeatSoundManager = HeartbeatSoundManager() func setupManager( @@ -31,13 +33,9 @@ class HeartbeatMainViewModel: ObservableObject { func handleRecordingSelection(_ recording: Recording) { print("🎵 Recording selected: \(recording.fileURL.lastPathComponent)") - // Set as last recording (but don't auto-play) - heartbeatSoundManager.lastRecording = recording - - // Switch to orb view (page 1) for playback - // User will need to tap the orb to start playback + // Show SavedRecordingPlaybackView withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { - currentPage = 1 + selectedRecording = recording } } } diff --git a/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift b/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift index 8866098..e45ca0d 100644 --- a/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift +++ b/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift @@ -19,7 +19,9 @@ class OrbLiveListenViewModel: ObservableObject { @Published var longPressScale: CGFloat = 1.0 @Published var dragOffset: CGFloat = 0 @Published var isDraggingToSave = false + @Published var isDraggingToDelete = false @Published var saveButtonScale: CGFloat = 1.0 + @Published var deleteButtonScale: CGFloat = 1.0 @Published var orbDragScale: CGFloat = 1.0 @Published var canSaveCurrentRecording = false @Published var currentTime: TimeInterval = 0 @@ -60,6 +62,7 @@ class OrbLiveListenViewModel: ObservableObject { } func setupPlayback(for recording: Recording) { + // This is only called for fresh recordings after countdown isPlaybackMode = true animateOrb = true audioPostProcessingManager.stop() @@ -68,30 +71,55 @@ class OrbLiveListenViewModel: ObservableObject { } func handleDragChange(value: SequenceGesture.Value, geometry: GeometryProxy) { - guard canSaveCurrentRecording else { return } + guard canSaveCurrentRecording || isPlaybackMode else { return } switch value { case .second(true, let drag): - isDraggingToSave = true - let translation = max(0, drag?.translation.height ?? 0) - dragOffset = translation - let maxDragDistance = geometry.size.height / 2 - let dragProgress = min(translation / maxDragDistance, 1.0) - withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.7)) { - orbDragScale = 1.0 - (dragProgress * 0.4) - saveButtonScale = 1.0 + (dragProgress * 0.4) + let translation = drag?.translation.height ?? 0 + + if translation > 0 { + // Dragging DOWN - Save + isDraggingToSave = true + isDraggingToDelete = false + dragOffset = translation + let maxDragDistance = geometry.size.height / 2 + let dragProgress = min(translation / maxDragDistance, 1.0) + withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.7)) { + orbDragScale = 1.0 - (dragProgress * 0.4) + saveButtonScale = 1.0 + (dragProgress * 0.4) + deleteButtonScale = 1.0 + } + } else if translation < 0 { + // Dragging UP - Delete + isDraggingToDelete = true + isDraggingToSave = false + dragOffset = translation + let maxDragDistance = geometry.size.height / 2 + let dragProgress = min(abs(translation) / maxDragDistance, 1.0) + withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.7)) { + orbDragScale = 1.0 - (dragProgress * 0.4) + deleteButtonScale = 1.0 + (dragProgress * 0.4) + saveButtonScale = 1.0 + } } default: break } } - func handleDragEnd(value: SequenceGesture.Value, geometry: GeometryProxy, onSave: @escaping () -> Void) { - guard canSaveCurrentRecording else { return } + func handleDragEnd(value: SequenceGesture.Value, geometry: GeometryProxy, onSave: @escaping () -> Void, onDelete: @escaping () -> Void) { + guard canSaveCurrentRecording || isPlaybackMode else { return } switch value { case .second(true, let drag): let translation = drag?.translation.height ?? 0 - if translation > geometry.size.height / 4 { + let threshold = geometry.size.height / 4 + + if translation > threshold { + // Dragged down enough - Save handleSaveRecording(onSave: onSave) + } else if translation < -threshold { + // Dragged up enough - Delete/Dismiss + handleDeleteRecording(onDelete: onDelete) } else { + // Not dragged enough - Reset resetDragState() } default: resetDragState() @@ -103,7 +131,9 @@ class OrbLiveListenViewModel: ObservableObject { dragOffset = 0 orbDragScale = 1.0 saveButtonScale = 1.0 + deleteButtonScale = 1.0 isDraggingToSave = false + isDraggingToDelete = false } } @@ -113,10 +143,41 @@ class OrbLiveListenViewModel: ObservableObject { saveButtonScale = 1.6 orbDragScale = 0.05 } - onSave() - canSaveCurrentRecording = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.resetDragState() + + // Wait for animation to complete before saving and navigating + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + onSave() + self.canSaveCurrentRecording = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.resetDragState() + } + } + } + + func handleDeleteRecording(onDelete: @escaping () -> Void) { + withAnimation(.interpolatingSpring(mass: 1, stiffness: 200, damping: 15)) { + deleteButtonScale = 1.6 + orbDragScale = 0.05 + } + + // Wait for animation to complete before deleting and navigating + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + // Call the delete callback + onDelete() + + // Stop playback and reset state + self.audioPostProcessingManager.stop() + self.stopPlaybackTimer() + self.canSaveCurrentRecording = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + self.isPlaybackMode = false + self.animateOrb = false + self.resetDragState() + } + } } } @@ -133,6 +194,7 @@ class OrbLiveListenViewModel: ObservableObject { func handleDoubleTap(onStart: @escaping () -> Void) { guard !isLongPressing, !isListening, !isPlaybackMode else { return } + withAnimation(.interpolatingSpring(mass: 2, stiffness: 100, damping: 20)) { animateOrb = true isListening = true @@ -222,31 +284,29 @@ class OrbLiveListenViewModel: ObservableObject { } func handleLongPressComplete(onStop: @escaping () -> Void) { - cancelLongPressCountdown() + // Stop the timer but don't trigger separate animation + longPressTimer?.invalidate() + longPressTimer = nil + longPressCountdown = 3 - withAnimation(.interpolatingSpring(mass: 2, stiffness: 100, damping: 20)) { + // 1. First animation: Move orb to center and reset scale + withAnimation(.spring(response: 0.8, dampingFraction: 0.8)) { isListening = false + isLongPressing = false + longPressScale = 1.0 animateOrb = false - isPlaybackMode = true - canSaveCurrentRecording = true } onStop() - } - - func handleSelectRecordingFromTimeline(_ recording: Recording, onSelect: @escaping (Recording) -> Void) { - isListening = false - withAnimation(.easeInOut(duration: 0.4)) { - isPlaybackMode = true - canSaveCurrentRecording = false - animateOrb = true + // 2. Second phase: Switch to playback mode after movement starts + // We delay this slightly to prevent the GestureModifier from reconstructing the view + // and cancelling the spring animation mid-flight. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + withAnimation(.easeIn(duration: 0.3)) { + self.isPlaybackMode = true + self.canSaveCurrentRecording = true + } } - - audioPostProcessingManager.stop() - audioPostProcessingManager.loadAndPlay(fileURL: recording.fileURL) - startPlaybackTimer() - - onSelect(recording) } } diff --git a/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift b/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift new file mode 100644 index 0000000..f6bf571 --- /dev/null +++ b/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift @@ -0,0 +1,235 @@ +// +// SavedRecordingPlaybackViewModel.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import Foundation +import SwiftUI +internal import Combine +import AudioKit +import SwiftData + +@MainActor +class SavedRecordingPlaybackViewModel: ObservableObject { + @Published var isPlaying = false + @Published var currentTime: TimeInterval = 0 + @Published var recordingName = "Heartbeat Recording" + @Published var editedName = "Heartbeat Recording" + @Published var isEditingName = false + @Published var showSuccessAlert = false + @Published var formattedDate = "" + + // Drag state + @Published var dragOffset: CGFloat = 0 + @Published var orbDragScale: CGFloat = 1.0 + @Published var isDraggingToDelete = false + @Published var deleteButtonScale: CGFloat = 1.0 + + private var playbackTimer: Timer? + private var audioManager: HeartbeatSoundManager? + private var currentRecording: Recording? + private var modelContext: ModelContext? + private var onRecordingUpdated: (() -> Void)? + + func setupPlayback(for recording: Recording, manager: HeartbeatSoundManager, modelContext: ModelContext, onRecordingUpdated: @escaping () -> Void) { + self.audioManager = manager + self.currentRecording = recording + self.modelContext = modelContext + self.onRecordingUpdated = onRecordingUpdated + + // Try to find the SavedHeartbeat entry to get custom name + let filePath = recording.fileURL.path + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.filePath == filePath } + ) + + if let savedHeartbeat = try? modelContext.fetch(descriptor).first { + // Use custom name if available, otherwise use filename + if let customName = savedHeartbeat.displayName, !customName.isEmpty { + self.recordingName = customName + } else { + let fileName = recording.fileURL.deletingPathExtension().lastPathComponent + self.recordingName = fileName.replacingOccurrences(of: "recording-", with: "Recording ") + } + } else { + // Fallback to filename + let fileName = recording.fileURL.deletingPathExtension().lastPathComponent + self.recordingName = fileName.replacingOccurrences(of: "recording-", with: "Recording ") + } + + self.editedName = recordingName + + // Format date + let formatter = DateFormatter() + formatter.dateFormat = "d MMMM yyyy" + self.formattedDate = formatter.string(from: recording.createdAt) + + // Start playback + manager.togglePlayback(recording: recording) + startPlaybackTimer(manager: manager) + } + + func togglePlayback(manager: HeartbeatSoundManager, recording: Recording) { + manager.togglePlayback(recording: recording) + + if manager.isPlayingPlayback { + startPlaybackTimer(manager: manager) + } else { + stopPlaybackTimer() + } + } + + func cleanup() { + stopPlaybackTimer() + audioManager?.player?.stop() + } + + func handleDragChange(value: DragGesture.Value, geometry: GeometryProxy) { + let translation = value.translation.height + dragOffset = translation + + // Only handle upward drag (delete) + if translation < 0 { + isDraggingToDelete = true + let progress = min(abs(translation) / (geometry.size.height / 4), 1.0) + orbDragScale = 1.0 - (progress * 0.3) + deleteButtonScale = 1.0 + (progress * 0.6) + } else { + resetDragState() + } + } + + func handleDragEnd(value: DragGesture.Value, geometry: GeometryProxy, onDelete: @escaping () -> Void) { + let translation = value.translation.height + let threshold = geometry.size.height / 4 + + if translation < -threshold { + // Dragged up enough - Delete + handleDelete(onDelete: onDelete) + } else { + // Not dragged enough - Reset + resetDragState() + } + } + + private func handleDelete(onDelete: @escaping () -> Void) { + withAnimation(.interpolatingSpring(mass: 1, stiffness: 200, damping: 15)) { + deleteButtonScale = 1.6 + orbDragScale = 0.05 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + onDelete() + } + } + + private func resetDragState() { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + dragOffset = 0 + orbDragScale = 1.0 + isDraggingToDelete = false + deleteButtonScale = 1.0 + } + } + + private func startPlaybackTimer(manager: HeartbeatSoundManager) { + stopPlaybackTimer() + isPlaying = true + + playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self, weak manager] _ in + guard let self = self, let manager = manager else { return } + + self.isPlaying = manager.isPlayingPlayback + + if !manager.isPlayingPlayback { + self.stopPlaybackTimer() + } + } + } + + func startEditing() { + isEditingName = true + } + + func saveName() { + recordingName = editedName + isEditingName = false + + // Show success alert + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = true + } + + // Hide alert after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + self.showSuccessAlert = false + } + } + + // Save to SwiftData + guard let recording = currentRecording, + let modelContext = modelContext else { + print("❌ Cannot save: missing recording or context") + return + } + + let filePath = recording.fileURL.path + print("🔍 Looking for SavedHeartbeat with path: \(filePath)") + + let descriptor = FetchDescriptor( + predicate: #Predicate { heartbeat in + heartbeat.filePath == filePath + } + ) + + do { + let results = try modelContext.fetch(descriptor) + print("📊 Found \(results.count) matching recordings") + + if let savedHeartbeat = results.first { + print("✏️ Updating recording: \(savedHeartbeat.filePath)") + print(" Old name: \(savedHeartbeat.displayName ?? "nil")") + print(" New name: \(editedName)") + + savedHeartbeat.displayName = editedName + try modelContext.save() + + print("✅ Saved recording name: \(editedName)") + + // Trigger reload to update UI + onRecordingUpdated?() + + // Sync to Firebase + if let syncManager = self.audioManager?.syncManager { + Task { + do { + try await syncManager.updateHeartbeatName(savedHeartbeat, newName: editedName) + } catch { + print("❌ Failed to sync name update: \(error)") + } + } + } + } else { + print("⚠️ Could not find SavedHeartbeat entry for path: \(filePath)") + // List all recordings for debugging + let allDescriptor = FetchDescriptor() + let allRecordings = try modelContext.fetch(allDescriptor) + print("📝 All recordings in database:") + for (index, rec) in allRecordings.enumerated() { + print(" [\(index)] \(rec.filePath)") + } + } + } catch { + print("❌ Error saving recording name: \(error)") + } + } + + private func stopPlaybackTimer() { + playbackTimer?.invalidate() + playbackTimer = nil + isPlaying = false + } +} diff --git a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift index edab16a..1bc5f4a 100644 --- a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift +++ b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift @@ -17,20 +17,21 @@ struct HeartbeatMainView: View { @State private var showRoomCode = false @State private var isInitialized = false - // Check if user is a mother private var isMother: Bool { authService.currentUser?.role == .mother } var body: some View { ZStack { - // TabView with swipe navigation TabView(selection: $viewModel.currentPage) { - // Left page: Timeline (default) PregnancyTimelineView( heartbeatSoundManager: viewModel.heartbeatSoundManager, showTimeline: .constant(true), onSelectRecording: viewModel.handleRecordingSelection, + onDisableSwipe: { disable in + print("🚫 Swipe disable requested: \(disable), allowTabViewSwipe will be: \(!disable)") + viewModel.allowTabViewSwipe = !disable + }, isMother: isMother, inputWeek: authService.currentUser?.pregnancyWeeks ) @@ -39,8 +40,6 @@ struct HeartbeatMainView: View { insertion: .scale(scale: 0.95).combined(with: .opacity), removal: .scale(scale: 1.05).combined(with: .opacity) )) - - // Right page: Orb Live Listen OrbLiveListenView( heartbeatSoundManager: viewModel.heartbeatSoundManager, showTimeline: Binding( @@ -55,7 +54,44 @@ struct HeartbeatMainView: View { )) } .tabViewStyle(.page(indexDisplayMode: .never)) + .highPriorityGesture( + // Block TabView swipe when navigation is active + !viewModel.allowTabViewSwipe ? DragGesture() : nil + ) .ignoresSafeArea() + + // Page indicator dots + VStack { + Spacer() + HStack(spacing: 8) { + // Timeline dot (page 0) + Circle() + .fill(viewModel.currentPage == 0 ? Color.white : Color.white.opacity(0.3)) + .frame(width: 8, height: 8) + .animation(.easeInOut(duration: 0.2), value: viewModel.currentPage) + + // Orb dot (page 1) + Circle() + .fill(viewModel.currentPage == 1 ? Color.white : Color.white.opacity(0.3)) + .frame(width: 8, height: 8) + .animation(.easeInOut(duration: 0.2), value: viewModel.currentPage) + } + .padding(.bottom, 20) + } + + // SavedRecordingPlaybackView overlay + if let recording = viewModel.selectedRecording { + SavedRecordingPlaybackView( + recording: recording, + heartbeatSoundManager: viewModel.heartbeatSoundManager, + showTimeline: Binding( + get: { false }, + set: { if $0 { viewModel.selectedRecording = nil } } + ) + ) + .transition(.move(edge: .trailing).combined(with: .opacity)) + .zIndex(10) + } } .preferredColorScheme(.dark) .onAppear { @@ -121,4 +157,5 @@ struct HeartbeatMainView: View { .modelContainer(for: SavedHeartbeat.self, inMemory: true) .environmentObject(AuthenticationService()) .environmentObject(HeartbeatSyncManager()) + .environmentObject(ThemeManager()) } diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index 6ce0548..a760ee2 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -1,9 +1,13 @@ import SwiftUI import SwiftData +// swiftlint:disable type_body_length struct OrbLiveListenView: View { @State private var showThemeCustomization = false @EnvironmentObject var themeManager: ThemeManager + + @State private var showSuccessAlert = false + @State private var successMessage = (title: "", subtitle: "") @Environment(\.modelContext) private var modelContext @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager @@ -18,20 +22,76 @@ struct OrbLiveListenView: View { GeometryReader { geometry in ZStack { backgroundView + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + topControlsView + .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + statusTextView + .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + orbView(geometry: geometry) // Save/Library Button (Only visible when dragging) saveButton(geometry: geometry) + // Delete Button (Only visible when dragging up) + deleteButton(geometry: geometry) + // Floating Button to Open Timeline manually - if !viewModel.isListening && !viewModel.isDraggingToSave { + if !viewModel.isListening && !viewModel.isDraggingToSave && !viewModel.isDraggingToDelete { libraryOpenButton(geometry: geometry) + .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) } coachMarkView + // Success Alert with dark overlay + if showSuccessAlert { + ZStack { + // Dark overlay + Color.black.opacity(0.6) + .ignoresSafeArea() + + // Alert on top + VStack { + HStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + Text(successMessage.title) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text(successMessage.subtitle) + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer() + } + .padding(20) + .glassEffect(.clear) + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + } + } + .transition(.opacity) + .zIndex(300) + } + if let context = tutorialViewModel.activeTutorial { TutorialOverlay(viewModel: tutorialViewModel, context: context) } @@ -132,15 +192,29 @@ struct OrbLiveListenView: View { .font(.system(size: 28)) .foregroundColor(.white) .frame(width: 77, height: 77) - .background(Circle().fill(Color.white.opacity(0.1))) .clipShape(Circle()) + .glassEffect(.clear) .scaleEffect(viewModel.saveButtonScale) - .position(x: geometry.size.width / 2, y: geometry.size.height - 100) + .position(x: geometry.size.width / 2, y: geometry.size.height - 46) .opacity(viewModel.isDraggingToSave ? min(viewModel.dragOffset / 150.0, 1.0) : 0.0) .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) .animation(.easeOut(duration: 0.2), value: viewModel.dragOffset) } + private func deleteButton(geometry: GeometryProxy) -> some View { + Image(systemName: "trash.fill") + .font(.system(size: 28)) + .foregroundColor(.red) + .frame(width: 77, height: 77) + .clipShape(Circle()) + .glassEffect(.clear) + .scaleEffect(viewModel.deleteButtonScale) + .position(x: geometry.size.width / 2, y: 50) + .opacity(viewModel.isDraggingToDelete ? min(abs(viewModel.dragOffset) / 150.0, 1.0) : 0.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + .animation(.easeOut(duration: 0.2), value: viewModel.dragOffset) + } + private var statusTextView: some View { VStack { Group { @@ -192,6 +266,7 @@ struct OrbLiveListenView: View { .animation(.easeInOut(duration: 0.2), value: viewModel.longPressScale) .animation(.easeInOut(duration: 0.2), value: viewModel.orbDragScale) .offset(y: viewModel.orbOffset(geometry: geometry) + viewModel.dragOffset) + .animation(.spring(response: 1.0, dampingFraction: 0.8), value: viewModel.isListening) .onTapGesture(count: 2) { viewModel.handleDoubleTap { heartbeatSoundManager.start() @@ -208,12 +283,43 @@ struct OrbLiveListenView: View { viewModel.handleDragChange(value: value, geometry: geometry) }, handleDragEnd: { value in - viewModel.handleDragEnd(value: value, geometry: geometry) { - heartbeatSoundManager.saveRecording() - withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { - showTimeline = true + viewModel.handleDragEnd(value: value, geometry: geometry, onSave: { + // Show alert first + successMessage = (title: "Saved!", subtitle: "Your recording is saved on timeline.") + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = true } - } + // Then save after alert is visible + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + heartbeatSoundManager.saveRecording() + // Navigate after another delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = false + showTimeline = true + } + } + } + }, onDelete: { + // Show alert first + successMessage = (title: "Deleted.", subtitle: "Your recording is deleted.") + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = true + } + // Then delete after alert is visible + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if let lastRecording = heartbeatSoundManager.lastRecording { + heartbeatSoundManager.deleteRecording(lastRecording) + } + // Navigate after another delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = false + showTimeline = true + } + } + } + }) }, handleLongPressChange: viewModel.handleLongPressChange, handleLongPressComplete: { @@ -317,6 +423,7 @@ struct OrbLiveListenView: View { } } } +// swiftlint:enable type_body_length // #Preview("Normal Mode") { // OrbLiveListenView( diff --git a/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift new file mode 100644 index 0000000..7b2452c --- /dev/null +++ b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift @@ -0,0 +1,350 @@ +// +// SavedRecordingPlaybackView.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import SwiftUI +import SwiftData + +struct SavedRecordingPlaybackView: View { + let recording: Recording + @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager + @Binding var showTimeline: Bool + + @StateObject private var viewModel = SavedRecordingPlaybackViewModel() + @StateObject var themeManager = ThemeManager() + @FocusState private var isNameFieldFocused: Bool + @Environment(\.modelContext) private var modelContext + + @State private var showDeleteSuccessAlert = false + + var body: some View { + GeometryReader { geometry in + ZStack { + backgroundView + + topControlsView(geometry: geometry) + + orbView(geometry: geometry) + + nameAndDateView + .zIndex(10) + + statusTextView + + deleteButton(geometry: geometry) + + if viewModel.showSuccessAlert { + successAlertView + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(100) + } + + // Delete Success Alert with dark overlay + if showDeleteSuccessAlert { + ZStack { + // Dark overlay + Color.black.opacity(0.6) + .ignoresSafeArea() + + // Alert on top + VStack { + HStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + Text("Deleted.") + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text("Your recording is deleted.") + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer() + } + .padding(20) + .glassEffect(.clear) + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + } + } + .transition(.opacity) + .zIndex(300) + } + } + .ignoresSafeArea() + + } + .onAppear { + viewModel.setupPlayback( + for: recording, + manager: heartbeatSoundManager, + modelContext: modelContext, + onRecordingUpdated: { + heartbeatSoundManager.loadFromSwiftData() + } + ) + } + .onDisappear { + viewModel.cleanup() + } + } + + private var backgroundView: some View { + ZStack { + Image(themeManager.selectedBackground.imageName) + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + + if viewModel.isPlaying { + BokehEffectView(amplitude: .constant(0.8)) + .opacity(0.5) + } + } + } + + private func topControlsView(geometry: GeometryProxy) -> some View { + VStack { + HStack { + Button { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showTimeline = true + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .frame(width: 50, height: 50) + } + .glassEffect(.clear) + + Spacer() + + if viewModel.isEditingName { + Button { + viewModel.saveName() + isNameFieldFocused = false + } label: { + Text("Save") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background( + Capsule() + .fill(Color(hex: "6B5B95")) + ) + } + .transition(.opacity.animation(.easeInOut)) + } else { + // Normal buttons + HStack { + Button { + } label: { + Image(systemName: "iphone.gen3.radiowaves.left.and.right") + .font(.body) + .foregroundColor(.white) + .frame(width: 48, height: 48) + } + .glassEffect(.clear) + + Button { + } label: { + Image(systemName: "square.and.arrow.up") + .font(.body) + .foregroundColor(.white) + .frame(width: 48, height: 48) + } + .glassEffect(.clear) + } + .transition(.opacity.animation(.easeInOut)) + } + } + .padding(.horizontal, 20) + .padding(.top, geometry.safeAreaInsets.top + 20) + + Spacer() + } + } + + private func orbView(geometry: GeometryProxy) -> some View { + ZStack { + AnimatedOrbView() + + if viewModel.isPlaying { + BokehEffectView(amplitude: .constant(0.8)) + .frame(width: 18, height: 18) + } + } + .frame(width: 200, height: 200) + .scaleEffect(viewModel.isPlaying ? 1.3 : 0.8) + .opacity(viewModel.isPlaying ? 1.0 : 0.4) + .animation(.easeInOut(duration: 0.5), value: viewModel.isPlaying) + .scaleEffect(viewModel.orbDragScale) + .offset(y: viewModel.dragOffset) + .gesture( + DragGesture() + .onChanged { value in + viewModel.handleDragChange(value: value, geometry: geometry) + } + .onEnded { value in + viewModel.handleDragEnd(value: value, geometry: geometry) { + // Show alert first + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showDeleteSuccessAlert = true + } + // Then delete after alert is visible + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + heartbeatSoundManager.deleteRecording(recording) + // Navigate after another delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showDeleteSuccessAlert = false + showTimeline = true + } + } + } + } + } + ) + .onTapGesture { + viewModel.togglePlayback(manager: heartbeatSoundManager, recording: recording) + } + } + + private var statusTextView: some View { + VStack { + Spacer() + VStack(spacing: 8) { + Text(viewModel.isPlaying ? "Playing" : "Tap to play") + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.7)) + + if !viewModel.isDraggingToDelete { + Text("Drag up to delete") + .font(.caption) + .foregroundColor(.white.opacity(0.5)) + } + } + .padding(.bottom, 60) + } + .opacity(viewModel.isDraggingToDelete ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + } + + private func deleteButton(geometry: GeometryProxy) -> some View { + Image(systemName: "trash.fill") + .font(.system(size: 28)) + .foregroundColor(.red) + .frame(width: 77, height: 77) + .clipShape(Circle()) + .glassEffect(.clear) + .scaleEffect(viewModel.deleteButtonScale) + .position(x: geometry.size.width / 2, y: 100) + .opacity(viewModel.isDraggingToDelete ? min(abs(viewModel.dragOffset) / 150.0, 1.0) : 0.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + .animation(.easeOut(duration: 0.2), value: viewModel.dragOffset) + } + + private var nameAndDateView: some View { + VStack { + Spacer() + .frame(height: 150) + + VStack(spacing: 8) { + // Editable name + TextField("Recording Name", text: $viewModel.editedName) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .padding(.vertical, 12) + .focused($isNameFieldFocused) + .submitLabel(.done) + .background( + viewModel.isEditingName ? + RoundedRectangle(cornerRadius: 20) + .fill(Color.white.opacity(0.15)) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + : nil + ) + .onChange(of: isNameFieldFocused) { _, isFocused in + if isFocused { + viewModel.startEditing() + } else { + if viewModel.isEditingName { + } + } + } + .onSubmit { + viewModel.saveName() + isNameFieldFocused = false + } + + Text(viewModel.formattedDate) + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + } + + Spacer() + } + } + + private var successAlertView: some View { + VStack { + HStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + Text("Changes saved!") + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text("Your changes is saved.") + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer() + } + .padding(20) + .glassEffect(.clear) + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + } + } +} + +#Preview { + let manager = HeartbeatSoundManager() + let mockRecording = Recording( + fileURL: URL(fileURLWithPath: "/tmp/test.caf"), + createdAt: Date() + ) + + return SavedRecordingPlaybackView( + recording: mockRecording, + heartbeatSoundManager: manager, + showTimeline: .constant(false) + ) + .environmentObject(ThemeManager()) +} diff --git a/Tiny/Features/Preview/OnboardingToTimelinePreview.swift b/Tiny/Features/Preview/OnboardingToTimelinePreview.swift deleted file mode 100644 index 4cfdcf9..0000000 --- a/Tiny/Features/Preview/OnboardingToTimelinePreview.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// OnboardingToTimelinePreview.swift -// Tiny -// -// Created by Tm Revanza Narendra Pradipta on 28/11/25. -// - -import SwiftUI - -struct OnboardingToTimelinePreview: View { - @State private var showWeekInput = true - @State private var selectedWeek: Int? - @State private var showTimeline = false - - var body: some View { - ZStack { - if showWeekInput { - // Week Input Screen - WeekInputView(onComplete: { week in - selectedWeek = week - - // Reset animation flag to see it every time - UserDefaults.standard.set(false, forKey: "hasSeenTimelineAnimation") - - // Transition to timeline - withAnimation(.easeInOut(duration: 0.5)) { - showWeekInput = false - showTimeline = true - } - }) - .transition(.opacity) - } - - if showTimeline, let week = selectedWeek { - // Timeline with animation - PregnancyTimelineView( - heartbeatSoundManager: createMockManager(), - showTimeline: .constant(true), - onSelectRecording: { recording in - print("Selected: \(recording.fileURL.lastPathComponent)") - }, - isMother: true, - inputWeek: week - ) - .transition(.opacity) - } - } - .preferredColorScheme(.dark) - } - - private func createMockManager() -> HeartbeatSoundManager { - let manager = HeartbeatSoundManager() - // Empty recordings to show placeholder dots - return manager - } -} - -#Preview { - OnboardingToTimelinePreview() -} diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift index 4876d07..c28f0de 100644 --- a/Tiny/Features/Profile/Views/ProfileView.swift +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -246,7 +246,20 @@ struct ProfileView: View { } private func featureCardRight(width: CGFloat) -> some View { - VStack(alignment: .leading, spacing: 8) { + // Calculate current pregnancy week dynamically + let currentWeek: Int = { + guard let pregnancyStartDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + // Fallback to user's stored pregnancy week if start date not available + return authService.currentUser?.pregnancyWeeks ?? 0 + } + + let calendar = Calendar.current + let now = Date() + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: now).weekOfYear ?? 0 + return weeksSinceStart + }() + + return VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "calendar") .font(.caption) @@ -259,7 +272,7 @@ struct ProfileView: View { Spacer() VStack(alignment: .center, spacing: 4) { - Text("20") + Text("\(currentWeek)") .font(.title) .fontWeight(.bold) .foregroundColor(Color("mainViolet")) @@ -487,45 +500,6 @@ private struct PhotoPickerButton: View { } } -struct ImagePicker: UIViewControllerRepresentable { - @Binding var image: UIImage? - let sourceType: UIImagePickerController.SourceType - @Environment(\.dismiss) var dismiss - - func makeUIViewController(context: Context) -> UIImagePickerController { - let picker = UIImagePickerController() - picker.sourceType = sourceType - picker.delegate = context.coordinator - picker.allowsEditing = false - return picker - } - - func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { - let parent: ImagePicker - - init(_ parent: ImagePicker) { - self.parent = parent - } - - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { - if let image = info[.originalImage] as? UIImage { - parent.image = image - } - parent.dismiss() - } - - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - parent.dismiss() - } - } -} - struct ThemeDummy: View { var body: some View { Text("Dummy Theme View") diff --git a/Tiny/Features/SignUp/Views/WeekInputView.swift b/Tiny/Features/SignUp/Views/WeekInputView.swift index 9dce18e..d71f649 100644 --- a/Tiny/Features/SignUp/Views/WeekInputView.swift +++ b/Tiny/Features/SignUp/Views/WeekInputView.swift @@ -51,7 +51,7 @@ enum PregnancyStage { struct WeekInputView: View { @State private var selectedWeek: Int = 20 - var onComplete: ((Int) -> Void)? // Callback when user completes + var onComplete: ((Int) -> Void)? // Callback when user completes var body: some View { ZStack { diff --git a/Tiny/Features/Timeline/Models/TimelineModel.swift b/Tiny/Features/Timeline/Models/TimelineModel.swift index 93640ae..dfd5cf1 100644 --- a/Tiny/Features/Timeline/Models/TimelineModel.swift +++ b/Tiny/Features/Timeline/Models/TimelineModel.swift @@ -15,6 +15,25 @@ enum WeekType { case placeholder // Future week placeholder } +enum TimelineItem: Identifiable, Equatable { + case recording(Recording) + case moment(Moment) + + var id: UUID { + switch self { + case .recording(let recording): return recording.id + case .moment(let moment): return moment.id + } + } + + var createdAt: Date { + switch self { + case .recording(let recording): return recording.createdAt + case .moment(let moment): return moment.createdAt + } + } +} + struct WeekSection: Identifiable, Equatable { let id = UUID() let weekNumber: Int diff --git a/Tiny/Features/Timeline/Views/DeleteAlert.swift b/Tiny/Features/Timeline/Views/DeleteAlert.swift new file mode 100644 index 0000000..393c50a --- /dev/null +++ b/Tiny/Features/Timeline/Views/DeleteAlert.swift @@ -0,0 +1,82 @@ +// +// delete alert.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import SwiftUI + +struct DeleteAlert: View { + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + VStack(alignment: .leading, spacing: 10) { + // Title + Text("Delete this moment?") + .font(.body) + .fontWeight(.bold) + .foregroundColor(.white) + + // Message + Text("This action is permanent and can't be undone.") + .font(.callout) + .foregroundColor(.white) + } + .padding(8) + .padding(.bottom, 24) + + // Buttons (side by side) + HStack(spacing: 12) { + // Delete Button (left) + Button { + } label: { + Text("Delete") + .font(.headline) + .foregroundColor(.red) + .frame(maxWidth: .infinity) + .frame(height: 48) + .cornerRadius(25) + } + .glassEffect(.regular.tint(.black.opacity(0.20))) + + // Keep Button (right, with gradient) + Button { + } label: { + Text("Keep") + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background( + RadialGradient( + colors: [ + Color(hex: "8376DB"), + Color(hex: "705AB1") + ], + center: .center, + startRadius: 5, + endRadius: 100 + ) + ) + .cornerRadius(25) + } + } + } + .frame(width: 300) + .padding(14) + .cornerRadius(20) + .glassEffect(.regular.tint(.black.opacity(0.50)), in: .rect(cornerRadius: 20.0)) + + } +} + +#Preview { + ZStack { + Image("librarySample1") + .resizable() + .frame(maxWidth: .infinity) + DeleteAlert() + } +} diff --git a/Tiny/Features/Timeline/Views/MainTimelineListView.swift b/Tiny/Features/Timeline/Views/MainTimelineListView.swift index f7df76e..21efd71 100644 --- a/Tiny/Features/Timeline/Views/MainTimelineListView.swift +++ b/Tiny/Features/Timeline/Views/MainTimelineListView.swift @@ -35,7 +35,7 @@ struct MainTimelineListView: View { ScrollViewReader { proxy in ZStack(alignment: .top) { let orbPositions = groupedData.indices.map { index in - topPadding + (CGFloat(index) * itemSpacing) + topPadding + CGFloat(index) * itemSpacing } SegmentedWave( diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 5332949..c641bcf 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -11,12 +11,14 @@ struct PregnancyTimelineView: View { @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager @Binding var showTimeline: Bool let onSelectRecording: (Recording) -> Void + let onDisableSwipe: (Bool) -> Void // Callback to disable TabView swipe let isMother: Bool // Add this parameter var inputWeek: Int? // Week from onboarding input @Namespace private var animation @State private var selectedWeek: WeekSection? @State private var groupedData: [WeekSection] = [] + @State private var isProfilePresented = false @EnvironmentObject var themeManager: ThemeManager // Animation support @@ -31,6 +33,7 @@ struct PregnancyTimelineView: View { Image(themeManager.selectedBackground.imageName) .resizable() .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() if let week = selectedWeek { @@ -38,6 +41,7 @@ struct PregnancyTimelineView: View { week: week, animation: animation, onSelectRecording: onSelectRecording, + heartbeatSoundManager: heartbeatSoundManager, isMother: isMother ) .transition(.opacity) @@ -47,11 +51,68 @@ struct PregnancyTimelineView: View { selectedWeek: $selectedWeek, animation: animation, animationController: animationController, - isFirstTimeVisit: isFirstTimeVisit + isFirstTimeVisit: isFirstTimeVisit, +// isMother: isMother ) .transition(.opacity) } - navigationButtons + + // Top navigation bar + GeometryReader { geometry in + VStack { + HStack { + if selectedWeek != nil { + Button { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + selectedWeek = nil + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .frame(width: 50, height: 50) + } + .glassEffect(.clear) + .matchedGeometryEffect(id: "navButton", in: animation) + } else { + Spacer() + } + + Spacer() + + if selectedWeek == nil { + Button { + isProfilePresented = true + } label: { + Group { + if let image = userProfile.profileImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.white.opacity(0.8)) + } + } + .frame(width: 50, height: 50) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + } + .opacity(isFirstTimeVisit ? (animationController.profileVisible ? 1.0 : 0.0) : 1.0) + .navigationDestination(isPresented: $isProfilePresented) { + ProfileView() + } + } + } + .padding(.horizontal, 20) + .padding(.top, geometry.safeAreaInsets.top + 39) + + Spacer() + } + } } .onAppear(perform: groupRecordings) } @@ -63,65 +124,11 @@ struct PregnancyTimelineView: View { print("Recordings changed: \(oldValue.count) -> \(newValue.count)") groupRecordings() } - } - - private var navigationButtons: some View { - GeometryReader { _ in - VStack { - // Top Bar - HStack { - if selectedWeek != nil { - // Back Button (Detail -> List) - Button { - withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { - selectedWeek = nil - } - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .bold)) - .foregroundColor(.white) - .frame(width: 50, height: 50) - .clipShape(Circle()) - } - .glassEffect(.clear) - .matchedGeometryEffect(id: "navButton", in: animation) - } else { - Spacer() - } - - Spacer() - - if selectedWeek == nil { - NavigationLink { - ProfileView() - } label: { - Group { - if let image = userProfile.profileImage { - Image(uiImage: image) - .resizable() - .scaledToFill() - } else { - Image(systemName: "person.crop.circle.fill") - .resizable() - .scaledToFit() - .foregroundColor(.white.opacity(0.8)) - } - } - .frame(width: 50, height: 50) - .clipShape(Circle()) - .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) - .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) - } - .opacity(isFirstTimeVisit ? (animationController.profileVisible ? 1.0 : 0.0) : 1.0) - .padding() - } - } - .padding() - - Spacer() - } + .onChange(of: isProfilePresented) { _, newValue in + // Disable TabView swipe when ProfileView is presented + print("🔄 Profile presented changed: \(newValue)") + onDisableSwipe(newValue) } - .ignoresSafeArea(.all, edges: .bottom) } private func initializeTimeline() { @@ -166,12 +173,12 @@ struct PregnancyTimelineView: View { groupRecordings() } } - + private func groupRecordings() { let raw = heartbeatSoundManager.savedRecordings print("📊 Grouping \(raw.count) recordings") - guard let currentPregnancyWeek = inputWeek else { + guard let initialPregnancyWeek = inputWeek else { print("⚠️ No pregnancy week available, showing empty timeline") groupedData = [] return @@ -180,16 +187,31 @@ struct PregnancyTimelineView: View { let calendar = Calendar.current let now = Date() - // Calculate when the pregnancy started (in weeks ago) - // If current pregnancy week is 20, pregnancy started 20 weeks ago - guard let pregnancyStartDate = calendar.date(byAdding: .weekOfYear, value: -currentPregnancyWeek, to: now) else { - print("⚠️ Could not calculate pregnancy start date") + // Store initial pregnancy week and date in UserDefaults if not already stored + if UserDefaults.standard.object(forKey: "pregnancyStartDate") == nil { + let pregnancyStartDate = calendar.date(byAdding: .weekOfYear, value: -initialPregnancyWeek, to: now)! + UserDefaults.standard.set(pregnancyStartDate, forKey: "pregnancyStartDate") + UserDefaults.standard.set(initialPregnancyWeek, forKey: "initialPregnancyWeek") + print("💾 Stored pregnancy start date: \(pregnancyStartDate)") + } + + // Get the stored pregnancy start date + guard let storedDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + print("⚠️ Could not get pregnancy start date") groupedData = [] return } + // Normalize to start of day to avoid time-based drift + let pregnancyStartDate = calendar.startOfDay(for: storedDate) + + // Calculate CURRENT pregnancy week based on time elapsed since pregnancy start + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: now).weekOfYear ?? 0 + let currentPregnancyWeek = weeksSinceStart - print("📅 Pregnancy started approximately: \(pregnancyStartDate)") - print("📅 Current pregnancy week: \(currentPregnancyWeek)") + print("📅 Pregnancy started: \(pregnancyStartDate)") + print("📅 Initial pregnancy week: \(initialPregnancyWeek)") + print("📅 Current pregnancy week (calculated): \(currentPregnancyWeek)") + print("📅 Weeks elapsed: \(currentPregnancyWeek - initialPregnancyWeek)") // Group recordings by pregnancy week let grouped = Dictionary(grouping: raw) { recording -> Int in @@ -204,7 +226,7 @@ struct PregnancyTimelineView: View { WeekSection(weekNumber: $0.key, recordings: $0.value.sorted(by: { $0.createdAt > $1.createdAt }), type: .recorded) }.sorted(by: { $0.weekNumber > $1.weekNumber }) // Reversed: newest (highest week) at bottom - // Always ensure we show the current week and next 2 weeks + // Show current week + next 2 weeks (as placeholders if no recordings) let weeksToShow = [currentPregnancyWeek, currentPregnancyWeek + 1, currentPregnancyWeek + 2] for week in weeksToShow where !recordedWeeks.contains(where: { $0.weekNumber == week }) { @@ -227,174 +249,63 @@ struct PregnancyTimelineView: View { #Preview { @Previewable @State var showTimeline = true - - // Create mock HeartbeatSoundManager with sample recordings + let mockManager = HeartbeatSoundManager() - let themeManager = ThemeManager() - - // Create sample recordings across different weeks let calendar = Calendar.current let now = Date() - - // 10 weeks of data going back in time - // Week 1 (9 weeks ago): 2 recordings - if let weekDate = calendar.date(byAdding: .day, value: -63, to: now) { - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week1-rec1.wav"), - createdAt: weekDate - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week1-rec2.wav"), - createdAt: weekDate.addingTimeInterval(3600) - ) - ) - } - - // Week 2 (8 weeks ago): 3 recordings - if let weekDate = calendar.date(byAdding: .day, value: -56, to: now) { - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week2-rec1.wav"), createdAt: weekDate)) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week2-rec2.wav"), - createdAt: weekDate.addingTimeInterval(7200) - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week2-rec3.wav"), - createdAt: weekDate.addingTimeInterval(14400) - ) - ) - } - - // Week 3 (7 weeks ago): 1 recording - if let weekDate = calendar.date(byAdding: .day, value: -49, to: now) { - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week3-rec1.wav"), createdAt: weekDate)) - } - - // Week 4 (6 weeks ago): 4 recordings - if let weekDate = calendar.date(byAdding: .day, value: -42, to: now) { - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week4-rec1.wav"), createdAt: weekDate)) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week4-rec2.wav"), - createdAt: weekDate.addingTimeInterval(3600) - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week4-rec3.wav"), - createdAt: weekDate.addingTimeInterval(7200))) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week4-rec4.wav"), - createdAt: weekDate.addingTimeInterval(10800) - ) - ) - } - - // Week 5 (5 weeks ago): 2 recordings - if let weekDate = calendar.date(byAdding: .day, value: -35, to: now) { - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week5-rec1.wav"), - createdAt: weekDate - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week5-rec2.wav"), - createdAt: weekDate.addingTimeInterval(5400) - ) - ) - } - // Week 6 (4 weeks ago): 3 recordings - if let weekDate = calendar.date(byAdding: .day, value: -28, to: now) { - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week6-rec1.wav"), - createdAt: weekDate - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week6-rec2.wav"), - createdAt: weekDate.addingTimeInterval(3600) - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week6-rec3.wav"), - createdAt: weekDate.addingTimeInterval(7200) - ) - ) - } + // Sample mock data per week + let mockData: [(weeksAgo: Int, count: Int)] = [ + (9, 2), // Week 1 + (8, 3), // Week 2 + (7, 1), // Week 3 + (6, 4), // Week 4 + (5, 2), // Week 5 + (4, 3), // Week 6 + (3, 2), // Week 7 + (2, 3), // Week 8 + (1, 1) // Week 9 + ] - // Week 7 (3 weeks ago): 2 recordings - if let weekDate = calendar.date(byAdding: .day, value: -21, to: now) { - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week7-rec1.wav"), - createdAt: weekDate - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week7-rec2.wav"), - createdAt: weekDate.addingTimeInterval(4800) - ) - ) + // Generate weeks 1–9 + for (weeksAgo, count) in mockData { + if let weekDate = calendar.date(byAdding: .day, value: -(weeksAgo * 7), to: now) { + for index in 0.. Void + @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager + let isMother: Bool + @State private var showMenu = false + @State private var showImagePicker = false + @State private var sourceType: UIImagePickerController.SourceType = .photoLibrary + @State private var selectedImage: UIImage? + @State private var selectedMoment: Moment? + @State private var showSuccessAlert = false + @State private var successMessage = (title: "", subtitle: "") + + private var currentItems: [TimelineItem] { + let calendar = Calendar.current + guard let storedDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + return [] + } + let pregnancyStartDate = calendar.startOfDay(for: storedDate) + + // Filter recordings + let recordings = heartbeatSoundManager.savedRecordings.compactMap { recording -> TimelineItem? in + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: recording.createdAt).weekOfYear ?? 0 + return weeksSinceStart == week.weekNumber ? .recording(recording) : nil + } + + // Filter moments + let moments = heartbeatSoundManager.savedMoments.compactMap { moment -> TimelineItem? in + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: moment.createdAt).weekOfYear ?? 0 + return weeksSinceStart == week.weekNumber ? .moment(moment) : nil + } + + // Combine and sort (Oldest first) + return (recordings + moments).sorted { $0.createdAt < $1.createdAt } + } + var body: some View { GeometryReader { geometry in ZStack { - // Background content: recordings list + // Scrollable content with orb and recordings recordingsScrollView(geometry: geometry) - .padding(.top, 180) // Make space for header + orb - // Foreground: Header with back button and title + // Fixed header with back button and week title VStack(spacing: 0) { - // Top navigation bar HStack { // Back button placeholder (actual button is in PregnancyTimelineView) Color.clear @@ -39,50 +70,214 @@ struct TimelineDetailView: View { Spacer() // Right side spacer for balance - Color.clear - .frame(width: 50, height: 50) + // Right side spacer or Add Button + // Right side spacer or Add Button + VStack(alignment: .trailing, spacing: 0) { + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + showMenu.toggle() + } + } label: { + Image(systemName: "plus") + .font(.system(size: 20, weight: .medium)) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background(.ultraThinMaterial) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + } + .rotationEffect(.degrees(showMenu ? 45 : 0)) + + // Custom Popover Menu + if showMenu { + VStack(spacing: 0) { + Button { + sourceType = .photoLibrary + showImagePicker = true + showMenu = false + } label: { + HStack { + Image(systemName: "photo.on.rectangle") + Text("Photo Library") + Spacer() + } + .padding() + .foregroundColor(.white) + } + + Divider() + .background(Color.white.opacity(0.2)) + + Button { + sourceType = .camera + showImagePicker = true + showMenu = false + } label: { + HStack { + Image(systemName: "camera") + Text("Take Photo") + Spacer() + } + .padding() + .foregroundColor(.white) + } + } + .frame(width: 200) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) + .padding(.top, 8) + .transition(.scale(scale: 0.8, anchor: .topTrailing).combined(with: .opacity)) + } + } + .zIndex(100) // Ensure menu is on top } .padding(.horizontal, 20) - .padding(.top, geometry.safeAreaInsets.top + 20) + .padding(.top, geometry.safeAreaInsets.top + 40) + .background( + LinearGradient( + colors: [Color.black.opacity(0.6), Color.clear], + startPoint: .top, + endPoint: .bottom + ) + ) - // Hero Orb + Spacer() + } + + // Moment Overlay + if let moment = selectedMoment { + MomentOverlayView( + moment: moment, + onDismiss: { selectedMoment = nil }, + onDelete: { + // Show alert first + successMessage = (title: "Deleted.", subtitle: "Your moment is deleted.") + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = true + } + // Then delete after alert is visible + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + heartbeatSoundManager.deleteMoment(moment) + selectedMoment = nil + // Hide alert after another delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = false + } + } + } + } + ) + .transition(.opacity) + .zIndex(100) + } + + // Success Alert with dark overlay + if showSuccessAlert { ZStack { - AnimatedOrbView(size: 115) - .shadow(color: .orange.opacity(0.6), radius: 30) + // Dark overlay + Color.black.opacity(0.6) + .ignoresSafeArea() + + // Alert on top + VStack { + HStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + Text(successMessage.title) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text(successMessage.subtitle) + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer() + } + .padding(20) + .glassEffect(.clear) + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + } } - .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) - .frame(height: 115) - .padding(.top, 20) - - Spacer() + .transition(.opacity) + .zIndex(300) + } + } + .onChange(of: selectedImage) { _, newImage in + if let image = newImage { + heartbeatSoundManager.saveMoment(image: image) } } + + .onTapGesture { + // Close menu when tapping outside + if showMenu { + withAnimation { + showMenu = false + } + } + } + .sheet(isPresented: $showImagePicker) { + ImagePicker(image: $selectedImage, sourceType: sourceType) + } } } private func recordingsScrollView(geometry: GeometryProxy) -> some View { - let recordings = week.recordings + let items = currentItems let recSpacing: CGFloat = 100 - let recHeight = max(geometry.size.height - 180, CGFloat(recordings.count) * recSpacing + 200) + let orbHeight: CGFloat = 115 + let topPadding: CGFloat = geometry.safeAreaInsets.top + 100 + + // Calculate total height + let contentHeight = orbHeight + CGFloat(items.count) * recSpacing + 200 return ScrollView(showsIndicators: false) { ZStack(alignment: .top) { - // Tighter Wavy Path for details + // Continuous Wave Path ContinuousWave( - totalHeight: recHeight, - period: 400, // Faster wave - amplitude: 60 // Smaller width + totalHeight: contentHeight - (orbHeight / 2), + period: 400, + amplitude: 60 ) .stroke( Color.white.opacity(0.15), style: StrokeStyle(lineWidth: 1, lineCap: .round) ) - .frame(width: geometry.size.width, height: recHeight) + .frame(width: geometry.size.width, height: contentHeight - (orbHeight / 2)) + .offset(y: orbHeight / 2) // Start from center of orb (visually bottom due to ZStack alignment) + + // Orb at the top + ZStack { + AnimatedOrbView(size: orbHeight) + .shadow(color: .orange.opacity(0.6), radius: 30) + } + .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) + .frame(height: orbHeight) + .frame(maxWidth: .infinity) // Center horizontally + // No top padding here, it sits at y=0 of the ZStack (start of wave) - // Glowing Dots (Recordings) - ForEach(Array(recordings.enumerated()), id: \.element.id) { index, recording in - let yPos: CGFloat = 40 + (CGFloat(index) * recSpacing) + // Items (Recordings & Moments) + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + let yPos = orbHeight + CGFloat(index) * recSpacing + 40 let xPos = TimelineLayout.calculateX( yCoor: yPos, width: geometry.size.width, @@ -90,44 +285,84 @@ struct TimelineDetailView: View { amplitude: 60 ) - HStack(spacing: 15) { - // Label Left or Right based on X position - if xPos > geometry.size.width / 2 { - recordingLabel(for: recording) - glowingDot - .onTapGesture { onSelectRecording(recording) } - } else { + HStack(spacing: 16) { + switch item { + case .recording(let recording): + // Glowing dot glowingDot .onTapGesture { onSelectRecording(recording) } + + // Label recordingLabel(for: recording) + + case .moment(let moment): + // Moment Thumbnail + momentThumbnail(for: moment) + .onTapGesture { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + selectedMoment = moment + } + } + + // Date Label + Text(moment.createdAt.formatted(date: .long, time: .omitted)) + .font(.system(size: 13)) + .foregroundColor(.white.opacity(0.6)) } + + Spacer() } - .frame(width: 300, height: 60) - .position(x: xPos, y: yPos) + .padding(.leading, xPos - (itemIsMoment(item) ? 25 : 6)) // Adjust padding based on item size + .frame(width: geometry.size.width, alignment: .leading) + .position(x: geometry.size.width / 2, y: yPos) } } - .frame(width: geometry.size.width, height: recHeight) + .frame(width: geometry.size.width, height: contentHeight) + .padding(.top, topPadding) } } + private func itemIsMoment(_ item: TimelineItem) -> Bool { + if case .moment = item { return true } + return false + } + + func momentThumbnail(for moment: Moment) -> some View { + AsyncImage(url: moment.fileURL) { phase in + if let image = phase.image { + image + .resizable() + .scaledToFill() + } else { + Color.gray.opacity(0.3) + } + } + .frame(width: 50, height: 50) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white, lineWidth: 2)) + .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2) + } + var glowingDot: some View { ZStack { - Circle().fill(Color.white).frame(width: 8, height: 8) - Circle().stroke(Color.white.opacity(0.5), lineWidth: 1).frame(width: 16, height: 16) - Circle().fill(Color.white.opacity(0.2)).frame(width: 24, height: 24).blur(radius: 4) + Circle() + .fill(Color.white) + .frame(width: 12, height: 12) + .shadow(color: .white.opacity(0.8), radius: 8, x: 0, y: 0) // Glow effect } } func recordingLabel(for recording: Recording) -> some View { - let dateName = recording.fileURL.deletingPathExtension().lastPathComponent - let text = formatTimestamp(dateName) - - return Text(text) - .font(.caption) - .foregroundColor(.white.opacity(0.8)) - .padding(6) - .background(Color.black.opacity(0.3)) - .cornerRadius(4) + VStack(alignment: .leading, spacing: 4) { + Text(recording.displayName ?? "Baby's Heartbeat") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + + Text(recording.createdAt.formatted(date: .long, time: .omitted)) + .font(.system(size: 13)) + .foregroundColor(.white.opacity(0.6)) + } } private func formatTimestamp(_ raw: String) -> String { @@ -142,6 +377,140 @@ struct TimelineDetailView: View { } } +struct MomentOverlayView: View { + let moment: Moment + let onDismiss: () -> Void + let onDelete: () -> Void + @State private var showDeleteAlert = false + + var body: some View { + ZStack { + // Darkened background + Color.black.opacity(0.8) + .ignoresSafeArea() + .onTapGesture { + onDismiss() + } + + VStack(spacing: 20) { + // Photo with overlay + ZStack(alignment: .bottom) { + AsyncImage(url: moment.fileURL) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity) + .frame(height: 400) + .clipped() + } else { + ProgressView() + .tint(.white) + .frame(maxWidth: .infinity) + .frame(height: 400) + } + } + .frame(maxWidth: .infinity) + .frame(height: 400) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .padding(.horizontal, 20) + + // Date overlay - no background, body font + Text(moment.createdAt.formatted(date: .long, time: .omitted)) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + .padding(.bottom, 20) + } + + // Delete Button + Button { + showDeleteAlert = true + } label: { + Image(systemName: "trash.fill") + .font(.system(size: 24)) + .foregroundColor(.red) + .frame(width: 50, height: 50) + .background(Circle().fill(Color.white.opacity(0.1))) + } + .padding(.top, 10) + } + + // Custom Alert (matching screenshot) + if showDeleteAlert { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { + showDeleteAlert = false + } + + VStack(alignment: .leading, spacing: 10) { + + VStack(alignment: .leading, spacing: 10) { + // Title + Text("Delete this moment?") + .font(.body) + .fontWeight(.bold) + .foregroundColor(.white) + + // Message + Text("This action is permanent and can't be undone.") + .font(.callout) + .foregroundColor(.white) + } + .padding(8) + .padding(.bottom, 24) + + // Buttons (side by side) + HStack(spacing: 12) { + // Delete Button (left) + Button { + showDeleteAlert = false + onDelete() + } label: { + Text("Delete") + .font(.headline) + .foregroundColor(.red) + .frame(maxWidth: .infinity) + .frame(height: 48) + .cornerRadius(25) + } + .glassEffect(.regular.tint(.black.opacity(0.20))) + + // Keep Button (right, with gradient) + Button { + showDeleteAlert = false + } label: { + Text("Keep") + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background( + RadialGradient( + colors: [ + Color(hex: "8376DB"), + Color(hex: "705AB1") + ], + center: .center, + startRadius: 5, + endRadius: 100 + ) + ) + .cornerRadius(25) + } + } + } + .frame(width: 300) + .padding(14) + .cornerRadius(20) + .glassEffect(.regular.tint(.black.opacity(0.50)), in: .rect(cornerRadius: 20.0)) + } + } + } +} + #Preview { struct PreviewWrapper: View { @Namespace var animation @@ -150,9 +519,8 @@ struct TimelineDetailView: View { let dummyURL = URL(fileURLWithPath: "Heartbeat-1715421234.m4a") let rec1 = Recording(fileURL: dummyURL, createdAt: Date()) - let rec2 = Recording(fileURL: dummyURL, createdAt: Date().addingTimeInterval(-3600)) // 1 hour ago + let rec2 = Recording(fileURL: dummyURL, createdAt: Date().addingTimeInterval(-3600)) - // Assuming WeekSection is a simple struct. Adjust if needed. return WeekSection(weekNumber: 24, recordings: [rec1, rec2, rec1]) } @@ -163,9 +531,9 @@ struct TimelineDetailView: View { onSelectRecording: { recording in print("Selected: \(recording.createdAt)") }, + heartbeatSoundManager: HeartbeatSoundManager(), isMother: true ) - // Dark background to visualize the white text/glowing effects .background(Color(red: 0.1, green: 0.1, blue: 0.2)) .environmentObject(ThemeManager()) } diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index 869b307..2622d11 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -84,10 +84,6 @@ "comment" : "A caption displayed alongside the slider for adjusting the gain of the microphone.", "isCommentAutoGenerated" : true }, - "20" : { - "comment" : "The number \"20\" displayed in the \"Pregnancy Age\" feature card.", - "isCommentAutoGenerated" : true - }, "20Hz" : { }, @@ -170,6 +166,10 @@ "comment" : "A title for the bottom sheet that appears when the user taps on their profile photo.", "isCommentAutoGenerated" : true }, + "Changes saved!" : { + "comment" : "A title displayed in an alert when a user successfully saves their changes to a recording.", + "isCommentAutoGenerated" : true + }, "Confidence" : { "comment" : "A label describing the confidence level of a heartbeat reading.", "isCommentAutoGenerated" : true @@ -213,6 +213,17 @@ "Current Status" : { "comment" : "The title of the section that displays the user's current heartbeat status.", "isCommentAutoGenerated" : true + }, + "Delete" : { + "comment" : "The text on a button that deletes an item.", + "isCommentAutoGenerated" : true + }, + "Delete this moment?" : { + "comment" : "The title of the alert that appears when the user tries to delete a moment.", + "isCommentAutoGenerated" : true + }, + "Deleted." : { + }, "Detection Confidence" : { "comment" : "A section header describing the confidence of the heartbeat data analysis.", @@ -225,6 +236,9 @@ "Drag to save" : { "comment" : "A text label that appears when the user drags their finger over the orb to save a new recording.", "isCommentAutoGenerated" : true + }, + "Drag up to delete" : { + }, "Dummy Theme View" : { "comment" : "A placeholder view representing a theme-related screen.", @@ -358,6 +372,10 @@ "comment" : "A button label that says \"Join Room\".", "isCommentAutoGenerated" : true }, + "Keep" : { + "comment" : "A button that cancels the deletion of a moment.", + "isCommentAutoGenerated" : true + }, "Key" : { "extractionState" : "manual" }, @@ -433,6 +451,10 @@ "comment" : "A button label that pauses a recording.", "isCommentAutoGenerated" : true }, + "Photo Library" : { + "comment" : "A button that allows the user to select an image from their photo library.", + "isCommentAutoGenerated" : true + }, "Play" : { "comment" : "A button label that says \"Play\".", "isCommentAutoGenerated" : true @@ -445,6 +467,10 @@ "comment" : "A label displayed next to a play/pause button in the tutorial overlay.", "isCommentAutoGenerated" : true }, + "Playing" : { + "comment" : "A text that appears when a recording is being played.", + "isCommentAutoGenerated" : true + }, "Playing with EQ" : { "comment" : "A message indicating that audio is being played with equalization applied.", "isCommentAutoGenerated" : true @@ -485,6 +511,10 @@ "comment" : "A header for the most recent heartbeat detections.", "isCommentAutoGenerated" : true }, + "Recording Name" : { + "comment" : "A label above the text field for editing a saved recording's name.", + "isCommentAutoGenerated" : true + }, "Recording: %@" : { "comment" : "A text label displaying the name of the most recently recorded audio file.", "isCommentAutoGenerated" : true @@ -637,6 +667,10 @@ "comment" : "A label indicating that the playback has been stopped.", "isCommentAutoGenerated" : true }, + "Take Photo" : { + "comment" : "The text for a button that allows the user to take a photo.", + "isCommentAutoGenerated" : true + }, "Tap orb to play" : { "comment" : "A text displayed when the user is not listening to audio and is not in playback mode. It instructs the user to tap the orb to play audio.", "isCommentAutoGenerated" : true @@ -653,6 +687,10 @@ "comment" : "A call-to-action label that appears at the bottom of the tutorial overlay, instructing the user to tap to continue.", "isCommentAutoGenerated" : true }, + "Tap to play" : { + "comment" : "A text displayed when a user taps on a recording to play it.", + "isCommentAutoGenerated" : true + }, "Tap twice" : { }, @@ -668,6 +706,10 @@ "comment" : "A button that allows the user to change the app's theme.", "isCommentAutoGenerated" : true }, + "This action is permanent and can't be undone." : { + "comment" : "The message displayed in the alert when deleting a moment.", + "isCommentAutoGenerated" : true + }, "Time" : { "comment" : "Data point on the heart rate trend chart.", "isCommentAutoGenerated" : true @@ -712,6 +754,14 @@ "comment" : "A description below a code display, explaining that they are connected to a room.", "isCommentAutoGenerated" : true }, + "Your changes is saved." : { + "comment" : "A description of what happens when they save their name edit.", + "isCommentAutoGenerated" : true + }, + "Your recording is deleted." : { + "comment" : "A message displayed after successfully deleting a recording.", + "isCommentAutoGenerated" : true + }, "Your recording will be saved automatically in your library." : { "comment" : "A description of what happens when the user stops a recording and it is saved.", "isCommentAutoGenerated" : true