Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions Tiny.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,6 @@
433435C42EACD4E4004C6B8C /* Tiny.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tiny.app; sourceTree = BUILT_PRODUCTS_DIR; };
433435D12EACD4E5004C6B8C /* TinyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TinyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
433435DB2EACD4E5004C6B8C /* TinyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TinyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
DEFE64C82ECB6123004B115A /* AudioKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AudioKit; path = "/Users/benedictusyoga/Library/Developer/Xcode/DerivedData/Tiny-avirkidcxafmvjhegizwaahcarxv/SourcePackages/checkouts/AudioKit"; sourceTree = "<absolute>"; };
DEFE64C92ECB6126004B115A /* AudioKitEX */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AudioKitEX; path = "/Users/benedictusyoga/Library/Developer/Xcode/DerivedData/Tiny-avirkidcxafmvjhegizwaahcarxv/SourcePackages/checkouts/AudioKitEX"; sourceTree = "<absolute>"; };
DEFE64CA2ECB6128004B115A /* Orb */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Orb; path = "/Users/benedictusyoga/Library/Developer/Xcode/DerivedData/Tiny-avirkidcxafmvjhegizwaahcarxv/SourcePackages/checkouts/Orb"; sourceTree = "<absolute>"; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
Expand Down Expand Up @@ -169,9 +166,6 @@
DE5E9C842EB354B200485B15 /* Frameworks */ = {
isa = PBXGroup;
children = (
DEFE64C82ECB6123004B115A /* AudioKit */,
DEFE64C92ECB6126004B115A /* AudioKitEX */,
DEFE64CA2ECB6128004B115A /* Orb */,
);
name = Frameworks;
sourceTree = "<group>";
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

114 changes: 114 additions & 0 deletions Tiny/Core/Services/Audio/HeartbeatSoundManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,18 @@ class HeartbeatSoundManager: NSObject, ObservableObject {
@Published var noiseGateThreshold: Float = 0.005

private let heartbeatDetector = HeartbeatDetector()
private var syncTimer: Timer?

override init() {
super.init()
engine = AudioEngine()
}

deinit {
syncTimer?.invalidate()
syncTimer = nil
}

func requestMicrophonePermission(completion: @escaping (Bool) -> Void) {
AVAudioApplication.requestRecordPermission { granted in
DispatchQueue.main.async {
Expand All @@ -119,6 +125,111 @@ class HeartbeatSoundManager: NSObject, ObservableObject {
}
}

// MARK: - Periodic Sync

func startPeriodicSync() {
// Stop any existing timer
syncTimer?.invalidate()

// Only start periodic sync for fathers (moms upload directly)
guard currentUserRole == .father,
let roomCode = currentRoomCode,
let syncManager = syncManager,
let modelContext = modelContext else {
print("⚠️ Periodic sync not started - missing requirements or user is mother")
return
}

print("πŸ”„ Starting periodic sync every 30 seconds for father")

// Create timer that fires every 30 seconds
syncTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
guard let self = self else { return }

Task { @MainActor in
await self.performSync()
}
}

// Also perform initial sync
Task { @MainActor in
await self.performSync()
}
}

func stopPeriodicSync() {
print("πŸ›‘ Stopping periodic sync")
syncTimer?.invalidate()
syncTimer = nil
}

private func performSync() async {
guard let roomCode = currentRoomCode,
let syncManager = syncManager,
let modelContext = modelContext else {
return
}

do {
print("πŸ”„ Performing periodic sync...")

// Sync heartbeats
_ = try await syncManager.syncHeartbeatsFromCloud(
roomCode: roomCode,
modelContext: modelContext,
isMother: false
)

// Sync moments
_ = try await syncManager.syncMomentsFromCloud(
roomCode: roomCode,
modelContext: modelContext
)

// Reload data
let updatedResults = try modelContext.fetch(FetchDescriptor<SavedHeartbeat>())
let updatedMoments = try modelContext.fetch(FetchDescriptor<SavedMoment>())

self.savedRecordings = updatedResults.compactMap { savedItem in
let filePath = savedItem.filePath
let fileURL = URL(fileURLWithPath: filePath)

guard FileManager.default.fileExists(atPath: filePath) else {
return nil
}

return Recording(
fileURL: fileURL,
createdAt: savedItem.timestamp,
displayName: savedItem.displayName
)
}

self.savedMoments = updatedMoments.compactMap { savedItem in
let storedPath = savedItem.filePath
let fileName = URL(fileURLWithPath: storedPath).lastPathComponent
let fileURL = self.getDocumentsDirectory().appendingPathComponent(fileName)

guard FileManager.default.fileExists(atPath: fileURL.path) else {
return nil
}

return Moment(
id: savedItem.id,
fileURL: fileURL,
createdAt: savedItem.timestamp
)
}

print("βœ… Periodic sync complete: \(self.savedRecordings.count) recordings, \(self.savedMoments.count) moments")

// Force UI update
self.objectWillChange.send()
} catch {
print("❌ Periodic sync failed: \(error.localizedDescription)")
}
}

func loadFromSwiftData() {
guard let modelContext = modelContext else { return }
do {
Expand Down Expand Up @@ -238,6 +349,9 @@ class HeartbeatSoundManager: NSObject, ObservableObject {

// Force UI update
self.objectWillChange.send()

// Start periodic sync for fathers
self.startPeriodicSync()
} catch {
print("❌ Cloud sync failed: \(error.localizedDescription)")
}
Expand Down
157 changes: 157 additions & 0 deletions Tiny/Core/Services/Authentication/AuthenticationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import Foundation
import FirebaseAuth
import FirebaseFirestore
import FirebaseStorage
import AuthenticationServices
import CryptoKit
internal import Combine
Expand Down Expand Up @@ -147,6 +148,162 @@ class AuthenticationService: ObservableObject {
isAuthenticated = false
}

func deleteAccount() async throws {
guard let user = auth.currentUser else {
throw NSError(domain: "AuthError", code: -1, userInfo: [NSLocalizedDescriptionKey: "No user is currently signed in"])
}

let userId = user.uid

do {
print("πŸ—‘οΈ Starting account deletion for user: \(userId)")

// 1. Delete all heartbeat recordings from Firestore and Storage
print("πŸ—‘οΈ Deleting heartbeat recordings...")

// Query by motherUserId (the field used in your schema)
var heartbeatsSnapshot = try await database.collection("heartbeats")
.whereField("motherUserId", isEqualTo: userId)
.getDocuments()

print(" Found \(heartbeatsSnapshot.documents.count) heartbeats by motherUserId")

// Also check for any heartbeats with userId field (legacy/fallback)
let legacyHeartbeats = try await database.collection("heartbeats")
.whereField("userId", isEqualTo: userId)
.getDocuments()

if !legacyHeartbeats.documents.isEmpty {
print(" Found \(legacyHeartbeats.documents.count) heartbeats by userId (legacy)")
}

// Combine both results
var allHeartbeatDocs = heartbeatsSnapshot.documents
allHeartbeatDocs.append(contentsOf: legacyHeartbeats.documents)

for document in allHeartbeatDocs {
let data = document.data()

// Delete from Firebase Storage if audioURL exists
if let audioURL = data["firebaseStorageURL"] as? String, !audioURL.isEmpty {
do {
let storageRef = Storage.storage().reference(forURL: audioURL)
try await storageRef.delete()
print(" βœ… Deleted audio file from Storage: \(document.documentID)")
} catch {
print(" ⚠️ Failed to delete audio file \(document.documentID): \(error.localizedDescription)")
}
}

// Delete from Firestore
try await database.collection("heartbeats").document(document.documentID).delete()
print(" βœ… Deleted heartbeat document: \(document.documentID)")
}

print("πŸ—‘οΈ Deleted \(allHeartbeatDocs.count) heartbeat recordings")

// 2. Delete all moments from Firestore and Storage
print("πŸ—‘οΈ Deleting moments...")

// Query by motherUserId (the field used in your schema)
var momentsSnapshot = try await database.collection("moments")
.whereField("motherUserId", isEqualTo: userId)
.getDocuments()

print(" Found \(momentsSnapshot.documents.count) moments by motherUserId")

// Also check for any moments with userId field (legacy/fallback)
let legacyMoments = try await database.collection("moments")
.whereField("userId", isEqualTo: userId)
.getDocuments()

if !legacyMoments.documents.isEmpty {
print(" Found \(legacyMoments.documents.count) moments by userId (legacy)")
}

// Combine both results
var allMomentDocs = momentsSnapshot.documents
allMomentDocs.append(contentsOf: legacyMoments.documents)

for document in allMomentDocs {
let data = document.data()

// Delete from Firebase Storage if imageURL exists
if let imageURL = data["firebaseStorageURL"] as? String, !imageURL.isEmpty {
do {
let storageRef = Storage.storage().reference(forURL: imageURL)
try await storageRef.delete()
print(" βœ… Deleted moment image from Storage: \(document.documentID)")
} catch {
print(" ⚠️ Failed to delete moment image \(document.documentID): \(error.localizedDescription)")
}
}

// Delete from Firestore
try await database.collection("moments").document(document.documentID).delete()
print(" βœ… Deleted moment document: \(document.documentID)")
}

print("πŸ—‘οΈ Deleted \(allMomentDocs.count) moments")

// 3. Handle room cleanup
if let roomCode = currentUser?.roomCode {
print("πŸ—‘οΈ Cleaning up room: \(roomCode)")

// Find the room
let snapshot = try await database.collection("rooms")
.whereField("code", isEqualTo: roomCode)
.limit(to: 1)
.getDocuments()

if let roomDoc = snapshot.documents.first {
let roomData = roomDoc.data()
let motherUserId = roomData["motherUserId"] as? String
let fatherUserId = roomData["fatherUserId"] as? String

// If user is the mother, delete the entire room
if motherUserId == userId {
print("πŸ—‘οΈ Deleting room (user is mother)")
try await database.collection("rooms").document(roomDoc.documentID).delete()
}
// If user is the father, just remove their reference
else if fatherUserId == userId {
print("πŸ—‘οΈ Removing father from room")
try await database.collection("rooms").document(roomDoc.documentID).updateData([
"fatherUserId": FieldValue.delete()
])
}
}
}

// 4. Delete user document from Firestore
print("πŸ—‘οΈ Deleting user document from Firestore")
try await database.collection("users").document(userId).delete()

// 5. Delete the Firebase Auth account
print("πŸ—‘οΈ Deleting Firebase Auth account")
try await user.delete()

// 6. Clear local state
currentUser = nil
isAuthenticated = false

print("βœ… Account successfully deleted from Firebase")

} catch let error as NSError {
// Handle re-authentication requirement
if error.code == AuthErrorCode.requiresRecentLogin.rawValue {
throw NSError(
domain: "AuthError",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "For security reasons, please sign out and sign in again before deleting your account."]
)
}
print("❌ Error during account deletion: \(error)")
throw error
}
}

private func fetchUserData(userId: String) {
database.collection("users").document(userId).addSnapshotListener { [weak self] snapshot, error in
guard let snapshot = snapshot, snapshot.exists, let data = snapshot.data() else {
Expand Down
17 changes: 9 additions & 8 deletions Tiny/Features/Authentication/Views/NameInputView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ struct NameInputView: View {
@State private var errorMessage: String?

func makeTitle() -> AttributedString {
var title = AttributedString("Hello Mom!")
let roleText = selectedRole == .mother ? "Mom" : "Dad"
var title = AttributedString("Hello \(roleText)!")

if let range = title.range(of: "Mom") {
if let range = title.range(of: roleText) {
title[range].foregroundColor = Color("mainYellow")
}

Expand All @@ -45,7 +46,7 @@ struct NameInputView: View {
.multilineTextAlignment(.center)
}
VStack(alignment: .center, spacing: 56) {
Image("tinyMom")
Image(selectedRole == .mother ? "tinyMom" : "tinyDad")
.resizable()
.frame(width: 126, height: 136)
TextField("I can call you...", text: $name)
Expand Down Expand Up @@ -97,11 +98,11 @@ struct NameInputView: View {
Task {
do {
try await authService.updateUserName(name: name.trimmingCharacters(in: .whitespaces))
// For mothers, proceed to week input before setting role
// For fathers, set role now and proceed to room code input
if selectedRole == .father {
try await authService.updateUserRole(role: selectedRole)
}

// Don't set role here for fathers - they need to go to RoomCodeInputView first
// For mothers, they'll set their role in WeekInputView
// For fathers, they'll set their role in RoomCodeInputView

await MainActor.run {
isLoading = false
onContinue()
Expand Down
Loading