diff --git a/Tiny.xcodeproj/project.pbxproj b/Tiny.xcodeproj/project.pbxproj index 9823b13..743a9d8 100644 --- a/Tiny.xcodeproj/project.pbxproj +++ b/Tiny.xcodeproj/project.pbxproj @@ -8,6 +8,10 @@ /* Begin PBXBuildFile section */ 430725D62EC5C791006234D7 /* Orb in Frameworks */ = {isa = PBXBuildFile; productRef = 430725D52EC5C791006234D7 /* Orb */; }; + DE00E4A12ED5AFA0005EC8F5 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = DE00E4A02ED5AFA0005EC8F5 /* FirebaseAnalytics */; }; + DE00E6792ED6D630005EC8F5 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = DE00E6782ED6D630005EC8F5 /* FirebaseAuth */; }; + DE00E67B2ED6D630005EC8F5 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = DE00E67A2ED6D630005EC8F5 /* FirebaseFirestore */; }; + DE00E67D2ED6D630005EC8F5 /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = DE00E67C2ED6D630005EC8F5 /* FirebaseStorage */; }; DE5E9C862EB354B200485B15 /* AudioKit in Frameworks */ = {isa = PBXBuildFile; productRef = DE5E9C852EB354B200485B15 /* AudioKit */; }; DE5E9C882EB354B700485B15 /* AudioKitEX in Frameworks */ = {isa = PBXBuildFile; productRef = DE5E9C872EB354B700485B15 /* AudioKitEX */; }; /* End PBXBuildFile section */ @@ -33,9 +37,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 = ""; }; - DEFE64C92ECB6126004B115A /* AudioKitEX */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AudioKitEX; path = "/Users/benedictusyoga/Library/Developer/Xcode/DerivedData/Tiny-avirkidcxafmvjhegizwaahcarxv/SourcePackages/checkouts/AudioKitEX"; sourceTree = ""; }; - DEFE64CA2ECB6128004B115A /* Orb */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Orb; path = "/Users/benedictusyoga/Library/Developer/Xcode/DerivedData/Tiny-avirkidcxafmvjhegizwaahcarxv/SourcePackages/checkouts/Orb"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -75,8 +76,12 @@ buildActionMask = 2147483647; files = ( 430725D62EC5C791006234D7 /* Orb in Frameworks */, + DE00E6792ED6D630005EC8F5 /* FirebaseAuth in Frameworks */, DE5E9C862EB354B200485B15 /* AudioKit in Frameworks */, + DE00E4A12ED5AFA0005EC8F5 /* FirebaseAnalytics in Frameworks */, + DE00E67D2ED6D630005EC8F5 /* FirebaseStorage in Frameworks */, DE5E9C882EB354B700485B15 /* AudioKitEX in Frameworks */, + DE00E67B2ED6D630005EC8F5 /* FirebaseFirestore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -121,9 +126,6 @@ DE5E9C842EB354B200485B15 /* Frameworks */ = { isa = PBXGroup; children = ( - DEFE64C82ECB6123004B115A /* AudioKit */, - DEFE64C92ECB6126004B115A /* AudioKitEX */, - DEFE64CA2ECB6128004B115A /* Orb */, ); name = Frameworks; sourceTree = ""; @@ -152,6 +154,10 @@ DE5E9C852EB354B200485B15 /* AudioKit */, DE5E9C872EB354B700485B15 /* AudioKitEX */, 430725D52EC5C791006234D7 /* Orb */, + DE00E4A02ED5AFA0005EC8F5 /* FirebaseAnalytics */, + DE00E6782ED6D630005EC8F5 /* FirebaseAuth */, + DE00E67A2ED6D630005EC8F5 /* FirebaseFirestore */, + DE00E67C2ED6D630005EC8F5 /* FirebaseStorage */, ); productName = tiny; productReference = 433435C42EACD4E4004C6B8C /* Tiny.app */; @@ -240,6 +246,7 @@ DEF686412EB33FA9004CC0C3 /* XCRemoteSwiftPackageReference "AudioKit" */, DEF686422EB33FB2004CC0C3 /* XCRemoteSwiftPackageReference "AudioKitEX" */, 430725D42EC5C69D006234D7 /* XCRemoteSwiftPackageReference "Orb" */, + DE00E49F2ED5AFA0005EC8F5 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); preferredProjectObjectVersion = 77; productRefGroup = 433435C52EACD4E4004C6B8C /* Products */; @@ -465,9 +472,12 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = Tiny/Tiny.entitlements; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6PN64DQKLX; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = YPC2WUCUT5; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Tiny/Info.plist; @@ -490,6 +500,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.miracle.tiny; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = TinyProvisionProfile; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -509,9 +521,12 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = Tiny/Tiny.entitlements; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6PN64DQKLX; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = YPC2WUCUT5; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Tiny/Info.plist; @@ -534,6 +549,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.miracle.tiny; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = TinyProvisionProfile; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -682,6 +699,14 @@ minimumVersion = 0.2.0; }; }; + DE00E49F2ED5AFA0005EC8F5 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 12.6.0; + }; + }; DEF686412EB33FA9004CC0C3 /* XCRemoteSwiftPackageReference "AudioKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/AudioKit/AudioKit.git"; @@ -706,6 +731,26 @@ package = 430725D42EC5C69D006234D7 /* XCRemoteSwiftPackageReference "Orb" */; productName = Orb; }; + DE00E4A02ED5AFA0005EC8F5 /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = DE00E49F2ED5AFA0005EC8F5 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + DE00E6782ED6D630005EC8F5 /* FirebaseAuth */ = { + isa = XCSwiftPackageProductDependency; + package = DE00E49F2ED5AFA0005EC8F5 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAuth; + }; + DE00E67A2ED6D630005EC8F5 /* FirebaseFirestore */ = { + isa = XCSwiftPackageProductDependency; + package = DE00E49F2ED5AFA0005EC8F5 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseFirestore; + }; + DE00E67C2ED6D630005EC8F5 /* FirebaseStorage */ = { + isa = XCSwiftPackageProductDependency; + package = DE00E49F2ED5AFA0005EC8F5 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseStorage; + }; DE5E9C852EB354B200485B15 /* AudioKit */ = { isa = XCSwiftPackageProductDependency; package = DEF686412EB33FA9004CC0C3 /* XCRemoteSwiftPackageReference "AudioKit" */; diff --git a/Tiny.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tiny.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0f390f5..17b9a88 100644 --- a/Tiny.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tiny.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "2ef496e610d0a5493faadac1f34a4061db69c0c83034b5b1cfa3b74771687ef6", + "originHash" : "c0723313e5b5a0580bce24f1656050a5f7f813ea864b849bdcb203e4dde1ad4e", "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, { "identity" : "audiokit", "kind" : "remoteSourceControl", @@ -19,6 +37,96 @@ "version" : "5.6.2" } }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "087bb95235f676c1a37e928769a5b6645dcbd325", + "version" : "12.6.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "35b601a60fbbea2de3ea461f604deaaa4d8bbd0c", + "version" : "3.2.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "c2d59acf17a8ba7ed80a763593c67c9c7c006ad1", + "version" : "12.5.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7", + "version" : "5.0.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, { "identity" : "orb", "kind" : "remoteSourceControl", @@ -27,6 +135,24 @@ "revision" : "8c5dda85e55638893f37657b56558968c3e11409", "version" : "0.2.0" } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", + "version" : "1.33.3" + } } ], "version" : 3 diff --git a/Tiny/App/ContentView.swift b/Tiny/App/ContentView.swift index 4257428..313861f 100644 --- a/Tiny/App/ContentView.swift +++ b/Tiny/App/ContentView.swift @@ -11,15 +11,21 @@ import SwiftData struct ContentView: View { @AppStorage("hasShownOnboarding") var hasShownOnboarding: Bool = false @StateObject private var heartbeatSoundManager = HeartbeatSoundManager() - @State private var showTimeline = false - + @Environment(\.modelContext) private var modelContext + @EnvironmentObject var authService: AuthenticationService + @EnvironmentObject var syncManager: HeartbeatSyncManager var body: some View { Group { if hasShownOnboarding { - HeartbeatMainView() + // After onboarding → always go to RootView (SignIn → Onboarding → Main) + RootView() + .environmentObject(heartbeatSoundManager) + .environmentObject(authService) + .environmentObject(syncManager) } else { + // Onboarding only OnBoardingView(hasShownOnboarding: $hasShownOnboarding) } } @@ -31,6 +37,25 @@ struct ContentView: View { } } +struct RootView: View { + @EnvironmentObject var authService: AuthenticationService + + var body: some View { + Group { + if !authService.isAuthenticated { + // Step 1: Landing screen with Sign in with Apple + SignInView() + } else if authService.currentUser?.role == nil { + // Step 2-4: Onboarding flow (role selection, name input, room code) + OnboardingCoordinator() + } else { + // Step 5: Main app - go to HeartbeatMainView + HeartbeatMainView() + } + } + } +} + #Preview { ContentView() .modelContainer(for: SavedHeartbeat.self, inMemory: true) diff --git a/Tiny/App/tinyApp.swift b/Tiny/App/tinyApp.swift index d9ae35f..2e41636 100644 --- a/Tiny/App/tinyApp.swift +++ b/Tiny/App/tinyApp.swift @@ -7,12 +7,20 @@ import SwiftUI import SwiftData +import FirebaseCore @main struct TinyApp: App { @StateObject var heartbeatSoundManager = HeartbeatSoundManager() + @StateObject var authService = AuthenticationService() + @StateObject var syncManager = HeartbeatSyncManager() + @State private var isShowingSplashScreen: Bool = true // Add state to control splash screen + init() { + FirebaseApp.configure() + } + // Define the container configuration var sharedModelContainer: ModelContainer = { let schema = Schema([ @@ -35,6 +43,9 @@ struct TinyApp: App { } else { ContentView() .environmentObject(heartbeatSoundManager) + .environmentObject(authService) + .environmentObject(syncManager) + .preferredColorScheme(.dark) } } .modelContainer(sharedModelContainer) diff --git a/Tiny/Core/Components/RoleButton.swift b/Tiny/Core/Components/RoleButton.swift new file mode 100644 index 0000000..857d7e8 --- /dev/null +++ b/Tiny/Core/Components/RoleButton.swift @@ -0,0 +1,44 @@ +// +// RoleButton.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 26/11/25. +// + +import SwiftUI + +struct RoleButton: View { + let title: String + let icon: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 20) { + Image(icon) + .resizable() + .scaledToFit() + .frame(width: 90, height: 90) + .padding(.all, 28) + .overlay { + Circle() + .stroke( + isSelected ? Color("tinyViolet") : Color.clear, + lineWidth: isSelected ? 5 : 0 + ) + } + .glassEffect() + Text(title) + .font(.title2.bold()) + .foregroundStyle(isSelected ? Color("tinyViolet") : Color.primary) + } + } + .buttonStyle(.plain) + } +} + +// #Preview{ +// RoleSelectionView() +// .preferredColorScheme(.dark) +// } diff --git a/Tiny/Core/Models/Room.swift b/Tiny/Core/Models/Room.swift new file mode 100644 index 0000000..2bc61e5 --- /dev/null +++ b/Tiny/Core/Models/Room.swift @@ -0,0 +1,22 @@ +// +// Room.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 26/11/25. +// + +import Foundation +import FirebaseFirestore + +struct Room: Identifiable, Codable { + @DocumentID var id: String? + var code: String + var motherUserId: String + var fatherUserId: String? + var createdAt: Date + + enum CodingKeys: String, CodingKey { + case id, code, motherUserId, fatherUserId, createdAt + } + +} diff --git a/Tiny/Core/Models/SavedHeartbeatModel.swift b/Tiny/Core/Models/SavedHeartbeatModel.swift index 3612aea..a77cae0 100644 --- a/Tiny/Core/Models/SavedHeartbeatModel.swift +++ b/Tiny/Core/Models/SavedHeartbeatModel.swift @@ -7,16 +7,85 @@ import SwiftData import Foundation +import FirebaseFirestore @Model class SavedHeartbeat { @Attribute(.unique) var id: UUID - var filePath: String + var filePath: String // Local file path var timestamp: Date - - init(filePath: String, timestamp: Date = Date()) { + + // Firebase fields + var firebaseId: String? // Document ID in Firestore (for metadata) + var motherUserId: String? + var roomCode: String? + var isShared: Bool // Whether mom has shared it with dad + var firebaseStorageURL: String? // Firebase Storage download URL + var isSyncedToCloud: Bool // Whether it's been uploaded to Firebase Storage + var pregnancyWeeks: Int? + + init(filePath: String, + timestamp: Date = Date(), + motherUserId: String? = nil, + roomCode: String? = nil, + isShared: Bool = true, // Changed default from false to true + firebaseStorageURL: String? = nil, + pregnancyWeeks: Int? = nil, + isSyncedToCloud: Bool = false, + firebaseId: String? = nil) { self.id = UUID() self.filePath = filePath self.timestamp = timestamp + self.motherUserId = motherUserId + self.roomCode = roomCode + self.isShared = isShared + self.firebaseStorageURL = firebaseStorageURL + self.pregnancyWeeks = pregnancyWeeks + self.isSyncedToCloud = isSyncedToCloud + self.firebaseId = firebaseId + } +} + +// Extension for Firestore conversion (metadata only) +extension SavedHeartbeat { + func toDictionary() -> [String: Any] { + var dict: [String: Any] = [ + "timestamp": Timestamp(date: timestamp), + "isShared": isShared, + "isSyncedToCloud": isSyncedToCloud + ] + + if let motherUserId = motherUserId { + dict["motherUserId"] = motherUserId + } + if let roomCode = roomCode { + dict["roomCode"] = roomCode + } + if let firebaseStorageURL = firebaseStorageURL { + dict["firebaseStorageURL"] = firebaseStorageURL + } + if let pregnancyWeeks = pregnancyWeeks { + dict["pregnancyWeeks"] = pregnancyWeeks + } + + return dict + } + + static func fromFirestore(id: String, data: [String: Any]) -> SavedHeartbeat? { + guard let timestamp = (data["timestamp"] as? Timestamp)?.dateValue() else { + return nil + } + + return SavedHeartbeat( + filePath: "", // Will be set after downloading + timestamp: timestamp, + motherUserId: data["motherUserId"] as? String, + roomCode: data["roomCode"] as? String, + isShared: data["isShared"] as? Bool ?? false, + firebaseStorageURL: data["firebaseStorageURL"] as? String, + pregnancyWeeks: data["pregnancyWeeks"] as? Int, + isSyncedToCloud: data["isSyncedToCloud"] as? Bool ?? false, + firebaseId: id + ) } } diff --git a/Tiny/Core/Models/User.swift b/Tiny/Core/Models/User.swift new file mode 100644 index 0000000..b790613 --- /dev/null +++ b/Tiny/Core/Models/User.swift @@ -0,0 +1,40 @@ +// +// User.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 26/11/25. +// + +import Foundation +import FirebaseFirestore + +enum UserRole: String, Codable { + case mother + case father +} + +struct User: Codable, Identifiable { + var id: String? + var email: String + var name: String? + var role: UserRole? + var pregnancyMonths: Int? + var roomCode: String? + var createdAt: Date + + init(id: String? = nil, + email: String, + name: String? = nil, + role: UserRole? = nil, + pregnancyMonths: Int? = nil, + roomCode: String? = nil, + createdAt: Date) { + self.id = id + self.email = email + self.name = name + self.role = role + self.pregnancyMonths = pregnancyMonths + self.roomCode = roomCode + self.createdAt = createdAt + } +} diff --git a/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift index 79d23d2..b1d0929 100644 --- a/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift +++ b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift @@ -43,6 +43,12 @@ enum HeartbeatFilterMode { // swiftlint:disable type_body_length @MainActor class HeartbeatSoundManager: NSObject, ObservableObject { + var currentUserRole: UserRole? // Add this after currentRoomCode + + var syncManager: HeartbeatSyncManager? + var currentUserId: String? + var currentRoomCode: String? + var modelContext: ModelContext? var engine: AudioEngine! @@ -50,7 +56,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { var gain: Fader? var mixer: Mixer? private let filterChainBuilder = AudioFilterChainBuilder() - + var highPassFilter: HighPassFilter? { filterChainBuilder.highPassFilter } var lowPassFilter: LowPassFilter? { filterChainBuilder.lowPassFilter } var secondaryLowPassFilter: LowPassFilter? { filterChainBuilder.secondaryLowPassFilter } @@ -61,7 +67,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { var fftTap: FFTTap? var recorder: NodeRecorder? var player: AudioPlayer? - + @Published var isPlaying = false @Published var isRunning = false @Published var isRecording = false @@ -83,14 +89,14 @@ class HeartbeatSoundManager: NSObject, ObservableObject { @Published var spatialMode: Bool = true @Published var proximityGain: Float = 2.0 @Published var noiseGateThreshold: Float = 0.005 - + private let heartbeatDetector = HeartbeatDetector() override init() { super.init() engine = AudioEngine() } - + func requestMicrophonePermission(completion: @escaping (Bool) -> Void) { AVAudioApplication.requestRecordPermission { granted in DispatchQueue.main.async { @@ -106,19 +112,69 @@ class HeartbeatSoundManager: NSObject, ObservableObject { let documentsURL = getDocumentsDirectory() DispatchQueue.main.async { - // ✅ Map the REAL timestamp from SwiftData - self.savedRecordings = results.map { savedItem in - let fileName = URL(fileURLWithPath: savedItem.filePath).lastPathComponent + // Map the REAL timestamp from SwiftData + self.savedRecordings = results.compactMap { savedItem in + let filePath = savedItem.filePath + let fileURL = URL(fileURLWithPath: filePath) + + // Verify file exists + if !FileManager.default.fileExists(atPath: filePath) { + print("⚠️ File missing: \(filePath)") + return nil + } - let currentURL = documentsURL.appendingPathComponent(fileName) + print("✅ Found recording: \(fileURL.lastPathComponent)") return Recording( - fileURL: currentURL, + fileURL: fileURL, createdAt: savedItem.timestamp ) } + print("✅ Loaded \(self.savedRecordings.count) recordings from SwiftData") + } + + // Sync from cloud if we have the necessary info + if let roomCode = currentRoomCode, let syncManager = syncManager { + Task { @MainActor in + do { + print("🔄 Starting cloud sync...") + let isMother = currentUserRole == .mother + + // Fetch heartbeats from cloud + let syncedHeartbeats = try await syncManager.syncHeartbeatsFromCloud( + roomCode: roomCode, + modelContext: modelContext, + isMother: isMother + ) + + // Reload from SwiftData after sync + let updatedResults = try modelContext.fetch(FetchDescriptor()) + + // Show all heartbeats for both mothers and fathers (all are shared by default) + self.savedRecordings = updatedResults.compactMap { savedItem in + let filePath = savedItem.filePath + let fileURL = URL(fileURLWithPath: filePath) + + // Verify file exists + if !FileManager.default.fileExists(atPath: filePath) { + print("⚠️ File missing after sync: \(filePath)") + return nil + } + + return Recording( + fileURL: fileURL, + createdAt: savedItem.timestamp + ) + } + print("✅ Reloaded \(self.savedRecordings.count) recordings after sync (isMother: \(isMother))") + + // Force UI update + self.objectWillChange.send() + } catch { + print("❌ Cloud sync failed: \(error.localizedDescription)") + } + } } - print("✅ Loaded \(self.savedRecordings.count) recordings from SwiftData") } catch { print("SwiftData load error: \(error)") } @@ -127,92 +183,92 @@ class HeartbeatSoundManager: NSObject, ObservableObject { func setupAudio() { do { cleanupAudio() - + let session = AVAudioSession.sharedInstance() - + try session.setCategory(.playAndRecord, mode: .measurement, options: [.allowBluetoothHFP, .allowBluetoothA2DP]) try useBottomMicrophone() try session.setActive(true) - + guard let input = engine.input else { print("No input available!") return } - + mic = input - + let (lowFreq, highFreq) = getFilterFrequencies(for: filterMode) - + if spatialMode { filterChainBuilder.setupSpatialAudioChain(input: input, lowFreq: lowFreq, highFreq: highFreq) } else { filterChainBuilder.setupTraditionalFilterChain(input: input, lowFreq: lowFreq, highFreq: highFreq, aggressiveFiltering: aggressiveFiltering) } - + guard let secondaryLowPass = filterChainBuilder.secondaryLowPassFilter else { print("Error: Secondary low pass filter not initialized") return } - + gain = Fader(secondaryLowPass) gain?.gain = AUValue(gainVal) - + guard let gainNode = gain else { print("Error: Gain node not initialized") return } - + mixer = Mixer(gainNode) - + guard let mixerNode = mixer else { print("Error: Mixer node not initialized") return } - + amplitudeTap = AmplitudeTap(mixerNode) { [weak self] amp in DispatchQueue.main.async { self?.processAmplitude(amp) } } - + fftTap = FFTTap(mixerNode) { [weak self] fftData in DispatchQueue.main.async { self?.processFFTData(fftData) } } - + recorder = nil do { recorder = try NodeRecorder(node: gainNode) } catch { print("Error creating recorder: \(error.localizedDescription)") } - + engine.output = mixerNode - + amplitudeTap?.start() fftTap?.start() print("Audio analysis taps started") - + } catch { print("Error setting up audio!: \(error.localizedDescription)") } } - + private func cleanupAudio() { amplitudeTap?.stop() amplitudeTap = nil - + fftTap?.stop() fftTap = nil - + recorder?.stop() recorder = nil - + if engine.avEngine.isRunning { engine.stop() } - + mixer = nil gain = nil filterChainBuilder.peakLimiter = nil @@ -222,25 +278,25 @@ class HeartbeatSoundManager: NSObject, ObservableObject { filterChainBuilder.bandPassFilter = nil filterChainBuilder.highPassFilter = nil mic = nil - + engine.output = nil } - + private func resetEngine() { if engine.avEngine.isRunning { engine.stop() } engine = AudioEngine() } - + func useBottomMicrophone() throws { let session = AVAudioSession.sharedInstance() let availableInputs = session.availableInputs ?? [] - + print("🎧 Available audio inputs:") for input in availableInputs { print("Input portType: \(input.portType.rawValue), portName: \(input.portName)") - + if let dataSources = input.dataSources, !dataSources.isEmpty { print("Data sources for \(input.portName):") for dataSource in dataSources { @@ -248,22 +304,22 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } } } - + // 1. Find the built-in microphone guard let builtInMic = availableInputs.first(where: { $0.portType == .builtInMic }) else { print("❌ No built-in mic found.") return } - + print("🎤 Built-in mic found: \(builtInMic.portName)") - + // 2. Look for the BOTTOM mic data source let bottomNames = ["Bottom", "Back", "Primary Bottom", "Microphone (Bottom)"] - + let bottomDataSource = builtInMic.dataSources?.first(where: { bottomNames.contains($0.dataSourceName) }) - + // 3. If found, set it if let bottom = bottomDataSource { print("✅ Selecting bottom microphone: \(bottom.dataSourceName)") @@ -274,16 +330,16 @@ class HeartbeatSoundManager: NSObject, ObservableObject { try session.setPreferredInput(builtInMic) } } - + func getDocumentsDirectory() -> URL { return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) [0] } - + func updateGain(_ newGain: Float) { gainVal = newGain gain?.gain = AUValue(newGain) } - + func updateBandpassRange(lowCutoff: Float, highCutoff: Float) { if spatialMode { filterChainBuilder.updateSpatialAudioChain(lowCutoff, highCutoff) @@ -291,7 +347,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { filterChainBuilder.updateTraditionalFilterChain(lowCutoff, highCutoff, aggressiveFiltering: aggressiveFiltering) } } - + func getFilterFrequencies(for mode: HeartbeatFilterMode) -> (Float, Float) { switch mode { case .standard: @@ -304,7 +360,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { return (15.0, 2500.0) // Optimized for spatial processing } } - + func setFilterMode(_ mode: HeartbeatFilterMode) { filterMode = mode if isRunning { @@ -312,7 +368,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { updateBandpassRange(lowCutoff: lowFreq, highCutoff: highFreq) } } - + private func processAmplitude(_ amplitude: Float) { // --- Updated logic for blinkAmplitude --- // Only update blinkAmplitude if it's lower than the current value @@ -330,39 +386,39 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } } // --- End of updated logic --- - + var processedAmplitude = amplitude - + // Apply noise gate to eliminate low-level noise if noiseReductionEnabled { processedAmplitude = applyNoiseGate(processedAmplitude) processedAmplitude = applyNoiseReduction(processedAmplitude) } - + if adaptiveGainEnabled { processedAmplitude = applyAdaptiveGain(processedAmplitude) } - + amplitudeVal = processedAmplitude - + if processedAmplitude > noiseFloor * 1.5 { signalQuality = min(1.0, (processedAmplitude - noiseFloor) / (noiseFloor * 2)) } else { signalQuality = max(0.0, signalQuality - 0.1) } - + updateNoiseFloor() } - + private func updateNoiseFloor() { let smoothingFactor: Float = aggressiveFiltering ? 0.98 : 0.95 let targetNoiseFloor = amplitudeVal * 0.3 // Use 30% of current amplitude as noise estimate noiseFloor = noiseFloor * smoothingFactor + targetNoiseFloor * (1.0 - smoothingFactor) } - + private func processFFTData(_ fftData: [Float]) { self.fftData = Array(fftData.prefix(128)) - + if let heartbeat = heartbeatDetector.detectHeartbeat(from: self.fftData) { DispatchQueue.main.async { self.heartbeatData.append(heartbeat) @@ -370,7 +426,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { self.heartbeatData.removeFirst() } self.currentBPM = heartbeat.bpm - + // Update blinkAmplitude when heartbeat is detected // Use S1 amplitude (the first, louder sound) to trigger brighter blink // Scale it appropriately and combine with confidence for better visibility @@ -380,81 +436,81 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } } } - + private func applyNoiseGate(_ amplitude: Float) -> Float { let threshold = max(noiseGateThreshold, noiseFloor * 1.5) - + if amplitude < threshold { return 0.0 } - + // Smooth transition to avoid clicking let smoothingFactor: Float = 0.1 let smoothedAmplitude = amplitude * smoothingFactor + (amplitude - threshold) * (1.0 - smoothingFactor) - + return max(0.0, smoothedAmplitude) } - + private func applyNoiseReduction(_ amplitude: Float) -> Float { let threshold = noiseFloor * (aggressiveFiltering ? 1.8 : 1.5) let reductionFactor: Float = aggressiveFiltering ? 0.1 : 0.2 - + if amplitude < threshold { return amplitude * reductionFactor } return amplitude } - + private func applyAdaptiveGain(_ amplitude: Float) -> Float { let targetAmplitude: Float = 0.3 let maxGain: Float = 3.0 let minGain: Float = 0.5 - + let error = targetAmplitude - amplitude let computedGain = 1.0 + (error * 0.5) - + let clampedGain = max(minGain, min(maxGain, computedGain)) - + // Apply proximity gain for spatial mode let finalGain = spatialMode ? (gainVal * clampedGain * proximityGain) : (gainVal * clampedGain) self.gain?.gain = AUValue(finalGain) - + return amplitude * clampedGain * (spatialMode ? proximityGain : 1.0) } - + func toggleNoiseReduction() { noiseReductionEnabled.toggle() } - + func toggleAdaptiveGain() { adaptiveGainEnabled.toggle() if !adaptiveGainEnabled { gain?.gain = AUValue(gainVal) } } - + func toggleAggressiveFiltering() { aggressiveFiltering.toggle() if isRunning { setupAudio() } } - + func updateNoiseGateThreshold(_ threshold: Float) { noiseGateThreshold = threshold } - + func toggleSpatialMode() { spatialMode.toggle() if isRunning { setupAudio() } } - + func updateProximityGain(_ gain: Float) { proximityGain = gain } - + func startRecording() { guard let recorder = recorder, !isRecording else { return } do { @@ -467,7 +523,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { print("Error starting recording: \(error.localizedDescription)") } } - + func stopRecording() { guard let recorder = recorder, recorder.isRecording else { return } recorder.stop() @@ -490,7 +546,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } } } - + func togglePlayback(recording: Recording) { if player?.isPlaying == true { player?.stop() @@ -503,12 +559,43 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } } else { do { + // Verify file exists before trying to play + let filePath = recording.fileURL.path + print("🎵 Attempting to play recording:") + print(" File URL: \(recording.fileURL)") + print(" File path: \(filePath)") + print(" File exists: \(FileManager.default.fileExists(atPath: filePath))") + + if !FileManager.default.fileExists(atPath: filePath) { + print("❌ File does not exist at path: \(filePath)") + + // Try to find the file in Documents directory + let documentsURL = getDocumentsDirectory() + let fileName = recording.fileURL.lastPathComponent + let alternativePath = documentsURL.appendingPathComponent(fileName) + + print(" Trying alternative path: \(alternativePath.path)") + print(" Alternative exists: \(FileManager.default.fileExists(atPath: alternativePath.path))") + + if FileManager.default.fileExists(atPath: alternativePath.path) { + print("✅ Found file at alternative path, using that") + // Use the alternative path + let alternativeRecording = Recording(fileURL: alternativePath, createdAt: recording.createdAt) + togglePlayback(recording: alternativeRecording) + return + } else { + print("❌ File not found anywhere") + return + } + } + if isRunning { stop() } - + resetEngine() - + + print("🎵 Creating AudioPlayer with URL: \(recording.fileURL)") player = AudioPlayer(url: recording.fileURL) player?.completionHandler = { [weak self] in DispatchQueue.main.async { @@ -518,7 +605,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } } } - + engine.output = player try engine.start() player?.play() @@ -526,12 +613,14 @@ class HeartbeatSoundManager: NSObject, ObservableObject { if lastRecording?.id == recording.id { lastRecording?.isPlaying = true } + print("✅ Playback started successfully") } catch { - print("Error playing back recording: \(error.localizedDescription)") + print("❌ Error playing back recording: \(error.localizedDescription)") + print(" Error details: \(error)") } } } - + func start() { if isPlayingPlayback { player?.stop() @@ -542,12 +631,12 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } resetEngine() setupAudio() - + do { try engine.start() isRunning = true print("Audio engine started successfully") - + if amplitudeTap?.isStarted == false { amplitudeTap?.start() print("Amplitude tap restarted") @@ -558,16 +647,16 @@ class HeartbeatSoundManager: NSObject, ObservableObject { isRunning = false } } - + func stop() { amplitudeTap?.stop() - + if engine.avEngine.isRunning { engine.stop() } - + cleanupAudio() - + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { do { try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) @@ -575,7 +664,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { print("Error deactivating audio session: \(error.localizedDescription)") } } - + isRunning = false } @@ -584,17 +673,50 @@ class HeartbeatSoundManager: NSObject, ObservableObject { self.savedRecordings.append(recording) guard let modelContext = modelContext else { return } - + + // Create entry with isShared = true by default let entry = SavedHeartbeat( filePath: recording.fileURL.path, - timestamp: recording.createdAt + timestamp: recording.createdAt, + isShared: true // Auto-share all heartbeats ) modelContext.insert(entry) do { try modelContext.save() + print("✅ Saved to SwiftData") + + // Debug logging + print("🔍 Checking Firebase upload requirements:") + print(" currentUserId: \(currentUserId ?? "nil")") + print(" currentRoomCode: \(currentRoomCode ?? "nil")") + print(" syncManager: \(syncManager != nil ? "available" : "nil")") + + // Upload to Firebase Storage if user is authenticated + if let userId = currentUserId, let roomCode = currentRoomCode, let syncManager = syncManager { + Task { @MainActor in + do { + print("📤 Uploading to Firebase Storage...") + print(" File: \(recording.fileURL.path)") + print(" User ID: \(userId)") + print(" Room Code: \(roomCode)") + + try await syncManager.uploadHeartbeat(entry, motherUserId: userId, roomCode: roomCode) + print("✅ Uploaded to Firebase Storage successfully") + print(" Storage URL: \(entry.firebaseStorageURL ?? "not set")") + print(" Is Shared: \(entry.isShared)") + } catch { + print("❌ Firebase upload failed: \(error.localizedDescription)") + } + } + } 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("SwiftData save failed: \(error)") + print("❌ SwiftData save failed: \(error)") } } } diff --git a/Tiny/Core/Services/Authentication/AuthenticationService.swift b/Tiny/Core/Services/Authentication/AuthenticationService.swift new file mode 100644 index 0000000..1613c75 --- /dev/null +++ b/Tiny/Core/Services/Authentication/AuthenticationService.swift @@ -0,0 +1,236 @@ +// +// AuthenticationService.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 26/11/25. +// + +import Foundation +import FirebaseAuth +import FirebaseFirestore +import AuthenticationServices +import CryptoKit +internal import Combine + +@MainActor +class AuthenticationService: ObservableObject { + @Published var currentUser: User? + @Published var isAuthenticated = false + + private let auth = Auth.auth() + private let database = Firestore.firestore() + + private var currentNonce: String? + + init() { + checkAuthStatus() + } + + func checkAuthStatus() { + if let firebaseUser = auth.currentUser { + fetchUserData(userId: firebaseUser.uid) + } + } + + func signInWithApple(authorization: ASAuthorization) async throws { + guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { + throw NSError(domain: "AuthError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid Credentials"]) + } + + guard let nonce = currentNonce else { + throw NSError(domain: "AuthError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid state: A login callback was received, but no login request was sent."]) + } + + guard let appleIDToken = appleIDCredential.identityToken else { + throw NSError(domain: "AuthError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unable to fetch identity token."]) + } + + guard let idTokenString = String(data: appleIDToken, encoding: .utf8) else { + throw NSError(domain: "AuthError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unable to serialize token string from data."]) + } + + let credential = OAuthProvider.appleCredential(withIDToken: idTokenString, rawNonce: nonce, fullName: appleIDCredential.fullName) + + let result = try await auth.signIn(with: credential) + + let userDoc = try await database.collection("users").document(result.user.uid).getDocument() + + if !userDoc.exists { + var displayName: String? + if let fullName = appleIDCredential.fullName { + let firstName = fullName.givenName ?? "" + let lastName = fullName.familyName ?? "" + displayName = "\(firstName) \(lastName)".trimmingCharacters(in: .whitespaces) + if displayName?.isEmpty == true { + displayName = nil + } + } + + let newUser = User( + id: result.user.uid, + email: result.user.email ?? "", + name: displayName, + role: nil, + pregnancyMonths: nil, + roomCode: nil, + createdAt: Date() + ) + + try database.collection("users").document(result.user.uid).setData(from: newUser) + self.currentUser = newUser + } else { + fetchUserData(userId: result.user.uid) + } + isAuthenticated = true + } + + func startSignInWithAppleFlow() -> String { + let nonce = randomNonceString() + currentNonce = nonce + return sha256(nonce) + } + + func updateUserRole(role: UserRole, pregnancyMonths: Int? = nil, roomCode: String? = nil) async throws { + guard let userId = auth.currentUser?.uid else { + return + } + + var updateData: [String: Any] = ["role": role.rawValue] + + if role == .mother, let months = pregnancyMonths { + updateData["pregnancyMonths"] = months + } + + if role == .father, let code = roomCode, !code.isEmpty { + updateData["roomCode"] = code + } + + try await database.collection("users").document(userId).updateData(updateData) + fetchUserData(userId: userId) + } + + func updateUserName(name: String) async throws { + guard let userId = auth.currentUser?.uid else { + return + } + + try await database.collection("users").document(userId).updateData(["name": name]) + fetchUserData(userId: userId) + } + + func createRoom() async throws -> String { + guard let userId = auth.currentUser?.uid, + currentUser?.role == .mother else { + throw NSError(domain: "AuthError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Only mothers can create rooms!"]) + } + + let roomCode = generateRoomCode() + + let room = Room( + code: roomCode, + motherUserId: userId, + fatherUserId: nil, + createdAt: Date() + ) + + let docRef = try database.collection("rooms").addDocument(from: room) + + try await database.collection("users").document(userId).updateData(["roomCode": roomCode]) + fetchUserData(userId: userId) + + return roomCode + } + + func signOut() throws { + try auth.signOut() + currentUser = nil + isAuthenticated = false + } + + 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 { + print("Error fetching user data: \(error?.localizedDescription ?? "Unknown error")") + return + } + + // Manually decode the user data + let user = User( + id: snapshot.documentID, + email: data["email"] as? String ?? "", + name: data["name"] as? String, + role: (data["role"] as? String).flatMap { UserRole(rawValue: $0) }, + pregnancyMonths: data["pregnancyMonths"] as? Int, + roomCode: data["roomCode"] as? String, + createdAt: (data["createdAt"] as? Timestamp)?.dateValue() ?? Date() + ) + + self?.currentUser = user + } + } + + private func randomNonceString(length: Int = 32) -> String { + precondition(length > 0) + var randomBytes = [UInt8](repeating: 0, count: length) + let errorCode = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes) + if errorCode != errSecSuccess { + fatalError("Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)") + } + let charset: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + let nonce = randomBytes.map { byte in + charset[Int(byte) % charset.count] + } + + return String(nonce) + } + + private func sha256(_ input: String) -> String { + let inputData = Data(input.utf8) + let hashedData = SHA256.hash(data: inputData) + let hashString = hashedData.compactMap { + String(format: "%02x", $0) + }.joined() + + return hashString + } + + private func generateRoomCode() -> String { + let characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return String((0..<6).map { _ in characters.randomElement()! }) + } + + func joinRoom(roomCode: String) async throws { + guard let userId = auth.currentUser?.uid else { + throw NSError(domain: "AuthError", code: -1, userInfo: [NSLocalizedDescriptionKey: "User not authenticated"]) + } + + print("🚪 User attempting to join room: \(roomCode)") + + // Find the room with this code + let snapshot = try await database.collection("rooms") + .whereField("code", isEqualTo: roomCode) + .limit(to: 1) + .getDocuments() + + guard let roomDoc = snapshot.documents.first else { + throw NSError(domain: "AuthError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Room not found with code: \(roomCode)"]) + } + + print("✅ Found room: \(roomDoc.documentID)") + + // Update the room to add father's user ID + try await database.collection("rooms").document(roomDoc.documentID).updateData([ + "fatherUserId": userId + ]) + + print("✅ Updated room with father's user ID") + + // Update user's roomCode + try await database.collection("users").document(userId).updateData([ + "roomCode": roomCode + ]) + + print("✅ Updated user's room code") + } + +} diff --git a/Tiny/Core/Services/Storage/FirebaseStorageService.swift b/Tiny/Core/Services/Storage/FirebaseStorageService.swift new file mode 100644 index 0000000..7e8237f --- /dev/null +++ b/Tiny/Core/Services/Storage/FirebaseStorageService.swift @@ -0,0 +1,144 @@ +// +// FirebaseStorageService.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 27/11/25. +// + +import Foundation +import FirebaseStorage +import FirebaseAuth +internal import Combine + +@MainActor +class FirebaseStorageService: ObservableObject { + private let storage = Storage.storage() + + // MARK: - Upload Heartbeat Audio + + /// Uploads an audio file to Firebase Storage + /// - Parameters: + /// - localFileURL: Local file URL of the audio + /// - motherUserId: User ID of the mother + /// - heartbeatId: Unique ID for this heartbeat + /// - Returns: Download URL of the uploaded file + func uploadHeartbeat( + localFileURL: URL, + motherUserId: String, + heartbeatId: String + ) async throws -> String { + // Create storage reference + // Path: heartbeats/{motherUserId}/{heartbeatId}.caf (changed from .m4a) + let storageRef = storage.reference() + let heartbeatRef = storageRef.child("heartbeats/\(motherUserId)/\(heartbeatId).caf") + + // Read file data + let data = try Data(contentsOf: localFileURL) + + // Set metadata + let metadata = StorageMetadata() + metadata.contentType = "audio/x-caf" // Changed from audio/m4a + metadata.customMetadata = [ + "motherUserId": motherUserId, + "heartbeatId": heartbeatId, + "uploadedAt": ISO8601DateFormatter().string(from: Date()) + ] + + // Upload file + _ = try await heartbeatRef.putDataAsync(data, metadata: metadata) + + // Get download URL + let downloadURL = try await heartbeatRef.downloadURL() + + return downloadURL.absoluteString + } + + // MARK: - Download Heartbeat Audio + + /// Downloads an audio file from Firebase Storage + /// - Parameters: + /// - downloadURL: Firebase Storage download URL + /// - heartbeatId: Unique ID for this heartbeat + /// - Returns: Local file URL where the audio was saved + /// Downloads an audio file from Firebase Storage + /// - Parameters: + /// - downloadURL: Firebase Storage download URL + /// - heartbeatId: Unique ID for this heartbeat + /// - timestamp: Timestamp for the recording (for filename) + /// - Returns: Local file URL where the audio was saved + func downloadHeartbeat( + downloadURL: String, + heartbeatId: String, + timestamp: Date + ) async throws -> URL { + guard let url = URL(string: downloadURL) 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 + let fileName = "recording-\(timeInterval).caf" + let localURL = documentsPath.appendingPathComponent(fileName) + + // If file already exists, return it + if FileManager.default.fileExists(atPath: localURL.path) { + print("✅ File already exists locally: \(localURL.path)") + return localURL + } + + // Download file + print("📥 Downloading from Firebase Storage...") + print(" Download URL: \(downloadURL)") + print(" Local path: \(localURL.path)") + + let storageRef = storage.reference(forURL: downloadURL) + _ = try await storageRef.writeAsync(toFile: localURL) + print("✅ Downloaded to: \(localURL.path)") + + return localURL + } + + // MARK: - Delete Heartbeat Audio + + /// Deletes an audio file from Firebase Storage + /// - Parameters: + /// - downloadURL: Firebase Storage download URL + func deleteHeartbeat(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 + /// - Parameter roomCode: The room code + /// - Returns: Array of storage references + func listSharedHeartbeats(for motherUserId: String) async throws -> [StorageReference] { + let storageRef = storage.reference() + let heartbeatsRef = storageRef.child("heartbeats/\(motherUserId)") + + let result = try await heartbeatsRef.listAll() + return result.items + } +} + +enum StorageError: LocalizedError { + case invalidURL + case uploadFailed + case downloadFailed + case deleteFailed + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid storage URL" + case .uploadFailed: + return "Failed to upload file" + case .downloadFailed: + return "Failed to download file" + case .deleteFailed: + return "Failed to delete file" + } + } +} diff --git a/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift b/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift new file mode 100644 index 0000000..40905d1 --- /dev/null +++ b/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift @@ -0,0 +1,340 @@ +// +// HeartbeatSyncManager.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 27/11/25. +// + +import Foundation +import FirebaseFirestore +import SwiftData +internal import Combine + +@MainActor +class HeartbeatSyncManager: ObservableObject { + private let dbf = Firestore.firestore() + private let storageService = FirebaseStorageService() + + @Published var isSyncing = false + @Published var syncError: String? + + // MARK: - Upload Heartbeat to Cloud + + /// Uploads a heartbeat recording to Firebase Storage and saves metadata to Firestore + func uploadHeartbeat( + _ heartbeat: SavedHeartbeat, + motherUserId: String, + roomCode: String + ) async throws { + isSyncing = true + defer { isSyncing = false } + + // 1. Upload audio file to Firebase Storage + let localURL = URL(fileURLWithPath: heartbeat.filePath) + let heartbeatId = heartbeat.id.uuidString + + print("📤 Uploading heartbeat...") + print(" Local path: \(localURL.path)") + print(" Heartbeat ID: \(heartbeatId)") + print(" Mother User ID: \(motherUserId)") + print(" Room Code: \(roomCode)") + print(" Is Shared: \(heartbeat.isShared)") + + let downloadURL = try await storageService.uploadHeartbeat( + localFileURL: localURL, + motherUserId: motherUserId, + heartbeatId: heartbeatId + ) + + print("✅ Upload complete. Download URL: \(downloadURL)") + + // 2. Update local model + heartbeat.firebaseStorageURL = downloadURL + heartbeat.isSyncedToCloud = true + heartbeat.motherUserId = motherUserId + heartbeat.roomCode = roomCode + // Keep the isShared value from the heartbeat (should be true by default) + + // 3. Save metadata to Firestore + let metadata: [String: Any] = [ + "heartbeatId": heartbeatId, + "motherUserId": motherUserId, + "roomCode": roomCode, + "firebaseStorageURL": downloadURL, + "timestamp": Timestamp(date: heartbeat.timestamp), + "isShared": heartbeat.isShared, // Use the heartbeat's isShared value + "pregnancyWeeks": heartbeat.pregnancyWeeks ?? 0, + "createdAt": Timestamp(date: Date()) + ] + + print("💾 Saving metadata to Firestore...") + print(" Metadata: \(metadata)") + + let docRef = try await dbf.collection("heartbeats").addDocument(data: metadata) + heartbeat.firebaseId = docRef.documentID + + print("✅ Metadata saved to Firestore") + print(" Document ID: \(docRef.documentID)") + print(" Collection: heartbeats") + } + + // MARK: - Share Heartbeat with Partner + + /// Marks a heartbeat as shared so the father can see it + func shareHeartbeat(_ heartbeat: SavedHeartbeat) async throws { + guard let firebaseId = heartbeat.firebaseId else { + throw SyncError.notSynced + } + + // Update Firestore + try await dbf.collection("heartbeats").document(firebaseId).updateData([ + "isShared": true + ]) + + // Update local model + heartbeat.isShared = true + } + + // MARK: - Unshare Heartbeat + + /// Marks a heartbeat as not shared + func unshareHeartbeat(_ heartbeat: SavedHeartbeat) async throws { + guard let firebaseId = heartbeat.firebaseId else { + throw SyncError.notSynced + } + + // Update Firestore + try await dbf.collection("heartbeats").document(firebaseId).updateData([ + "isShared": false + ]) + + // Update local model + heartbeat.isShared = false + } + + // MARK: - Fetch Shared Heartbeats (for fathers) + + /// Fetches all shared heartbeats for a specific room + func fetchSharedHeartbeats(roomCode: String) async throws -> [HeartbeatMetadata] { + print("🔍 Fetching heartbeats for room code: '\(roomCode)'") + + // Simplified query - only filter by roomCode, then filter isShared in code + let snapshot = try await dbf.collection("heartbeats") + .whereField("roomCode", isEqualTo: roomCode) + .getDocuments() + + print("📊 Query returned \(snapshot.documents.count) documents") + + // Log all documents for debugging + for (index, doc) in snapshot.documents.enumerated() { + let data = doc.data() + print(" Document \(index + 1):") + print(" ID: \(doc.documentID)") + print(" roomCode: \(data["roomCode"] as? String ?? "nil")") + print(" isShared: \(data["isShared"] as? Bool ?? false)") + print(" motherUserId: \(data["motherUserId"] as? String ?? "nil")") + print(" storageURL: \(data["firebaseStorageURL"] as? String ?? "nil")") + } + + // Filter and sort in code to avoid needing a composite index + let heartbeats = snapshot.documents.compactMap { doc -> HeartbeatMetadata? in + let data = doc.data() + + // Only include shared heartbeats + guard let isShared = data["isShared"] as? Bool, isShared else { + print(" ⚠️ Skipping document \(doc.documentID) - isShared is false or missing") + return nil + } + + let metadata = HeartbeatMetadata( + id: doc.documentID, + heartbeatId: data["heartbeatId"] 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: isShared, + pregnancyWeeks: data["pregnancyWeeks"] as? Int + ) + + print(" ✅ Including heartbeat: \(metadata.id)") + return metadata + } + + // Sort by timestamp descending (newest first) + let sorted = heartbeats.sorted { $0.timestamp > $1.timestamp } + print("📦 Returning \(sorted.count) shared heartbeats") + return sorted + } + + // MARK: - Fetch All Heartbeats for Room (for mothers) + + /// Fetches ALL heartbeats for a specific room (including non-shared) + func fetchAllHeartbeatsForRoom(roomCode: String) async throws -> [HeartbeatMetadata] { + print("🔍 Fetching ALL heartbeats for room code: '\(roomCode)'") + + let snapshot = try await dbf.collection("heartbeats") + .whereField("roomCode", isEqualTo: roomCode) + .getDocuments() + + print("📊 Query returned \(snapshot.documents.count) documents") + + let heartbeats = snapshot.documents.compactMap { doc -> HeartbeatMetadata? in + let data = doc.data() + + return HeartbeatMetadata( + id: doc.documentID, + heartbeatId: data["heartbeatId"] 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 + ) + } + + let sorted = heartbeats.sorted { $0.timestamp > $1.timestamp } + print("📦 Returning \(sorted.count) heartbeats") + return sorted + } + + // MARK: - Download Heartbeat (for fathers) + + /// Downloads a heartbeat audio file from Firebase Storage + func downloadHeartbeat(metadata: HeartbeatMetadata) async throws -> URL { + return try await storageService.downloadHeartbeat( + downloadURL: metadata.firebaseStorageURL, + heartbeatId: metadata.heartbeatId, + timestamp: metadata.timestamp // Pass the timestamp + ) + } + + // MARK: - Delete Heartbeat + + /// Deletes a heartbeat from both Storage and Firestore + func deleteHeartbeat(_ heartbeat: SavedHeartbeat) async throws { + // Delete from Storage if synced + if let storageURL = heartbeat.firebaseStorageURL { + try await storageService.deleteHeartbeat(downloadURL: storageURL) + } + + // Delete from Firestore + if let firebaseId = heartbeat.firebaseId { + try await dbf.collection("heartbeats").document(firebaseId).delete() + } + } + + // MARK: - Sync All Heartbeats from Cloud + + /// Syncs all heartbeats from Firestore and downloads missing audio files + func syncHeartbeatsFromCloud( + roomCode: String, + modelContext: ModelContext, + isMother: Bool = true + ) async throws -> [SavedHeartbeat] { + print("🔄 Syncing heartbeats from cloud...") + print(" Room Code: \(roomCode)") + print(" Is Mother: \(isMother)") + + // Fetch ALL heartbeats for the room (both mothers and fathers see everything) + let metadataList = try await fetchAllHeartbeatsForRoom(roomCode: roomCode) + + print("📦 Found \(metadataList.count) heartbeats in cloud") + + var syncedHeartbeats: [SavedHeartbeat] = [] + + for metadata in metadataList { + print(" Processing heartbeat: \(metadata.id)") + print(" Timestamp: \(metadata.timestamp)") + print(" isShared: \(metadata.isShared)") + print(" motherUserId: \(metadata.motherUserId)") + + // Check if we already have this heartbeat locally + let allHeartbeats = try modelContext.fetch(FetchDescriptor()) + let existingHeartbeat = allHeartbeats.first { $0.firebaseId == metadata.id } + + if let existing = existingHeartbeat { + print(" ✅ Already have heartbeat: \(metadata.id)") + print(" Local path: \(existing.filePath)") + + // Verify the file exists + if FileManager.default.fileExists(atPath: existing.filePath) { + print(" ✅ File exists on disk") + } else { + print(" ⚠️ File missing on disk, re-downloading...") + // Re-download if file is missing + do { + let localURL = try await downloadHeartbeat(metadata: metadata) + existing.filePath = localURL.path + print(" ✅ Re-downloaded to: \(localURL.path)") + } catch { + print(" ❌ Re-download failed: \(error)") + } + } + + syncedHeartbeats.append(existing) + } else { + print(" 📥 Downloading new heartbeat: \(metadata.id)") + + do { + let localURL = try await downloadHeartbeat(metadata: metadata) + print(" Downloaded to: \(localURL.path)") + + let heartbeat = SavedHeartbeat( + filePath: localURL.path, + timestamp: metadata.timestamp, + motherUserId: metadata.motherUserId, + roomCode: metadata.roomCode, + isShared: metadata.isShared, + firebaseStorageURL: metadata.firebaseStorageURL, + pregnancyWeeks: metadata.pregnancyWeeks, + isSyncedToCloud: true, + firebaseId: metadata.id + ) + + modelContext.insert(heartbeat) + syncedHeartbeats.append(heartbeat) + print(" ✅ Saved heartbeat to SwiftData: \(metadata.id)") + } catch { + print(" ❌ Failed to download heartbeat \(metadata.id): \(error)") + } + } + } + + try modelContext.save() + print("✅ Sync complete: \(syncedHeartbeats.count) heartbeats") + + return syncedHeartbeats + } +} + +// MARK: - Supporting Types + +struct HeartbeatMetadata: Identifiable { + let id: String + let heartbeatId: String + let motherUserId: String + let roomCode: String + let firebaseStorageURL: String + let timestamp: Date + let isShared: Bool + let pregnancyWeeks: Int? +} + +enum SyncError: LocalizedError { + case notSynced + case uploadFailed + case downloadFailed + + var errorDescription: String? { + switch self { + case .notSynced: + return "Heartbeat has not been synced to cloud yet" + case .uploadFailed: + return "Failed to upload heartbeat" + case .downloadFailed: + return "Failed to download heartbeat" + } + } +} diff --git a/Tiny/Features/Authentication/Views/NameInputView.swift b/Tiny/Features/Authentication/Views/NameInputView.swift new file mode 100644 index 0000000..b5e95b1 --- /dev/null +++ b/Tiny/Features/Authentication/Views/NameInputView.swift @@ -0,0 +1,119 @@ +// +// NameInputView.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 27/11/25. +// + +import SwiftUI + +struct NameInputView: View { + @EnvironmentObject var authService: AuthenticationService + let selectedRole: UserRole + let onContinue: () -> Void + + @State private var name: String = "" + @State private var isLoading = false + @State private var errorMessage: String? + + func makeTitle() -> AttributedString { + var title = AttributedString("Hello Mom!") + + if let range = title.range(of: "Mom") { + title[range].foregroundColor = Color("mainYellow") + } + + return title + } + + var body: some View { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .clipped() + .ignoresSafeArea() + + VStack(spacing: 100) { + VStack(spacing: 12) { + Text(makeTitle()) + .font(.title2.bold()) + Text("Tell me your name and step into your amazing journey") + .font(.body) + .multilineTextAlignment(.center) + } + VStack(alignment: .center, spacing: 56) { + Image("tinyMom") + .resizable() + .frame(width: 126, height: 136) + TextField("I can call you...", text: $name) + .font(.body) + .multilineTextAlignment(.center) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(32) + .autocorrectionDisabled() + .padding(.horizontal, 40) + if let error = errorMessage { + Text(error) + .foregroundColor(.red) + .font(.caption) + .padding() + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + } + Button(action: handleContinue) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Text("Continue") + .fontWeight(.semibold) + .frame(height: 48) + .padding(.horizontal, 56) + .glassEffect() + + } + } + .buttonStyle(.plain) + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isLoading) + + } + } + .onAppear { + if let userName = authService.currentUser?.name, !userName.isEmpty { + name = userName + } + } + } + + func handleContinue() { + isLoading = true + errorMessage = nil + + Task { + do { + try await authService.updateUserName(name: name.trimmingCharacters(in: .whitespaces)) + if selectedRole == .mother { + try await authService.updateUserRole(role: selectedRole, pregnancyMonths: 5) + } else { + onContinue() + } + } catch { + errorMessage = error.localizedDescription + isLoading = false + } + + if selectedRole == .father { + isLoading = false + } + } + } +} + +// #Preview { +// NameInputView(selectedRole: .mother) +// .environmentObject(AuthenticationService()) +// .preferredColorScheme(.dark) +// } diff --git a/Tiny/Features/Authentication/Views/OnboardingCoordinator.swift b/Tiny/Features/Authentication/Views/OnboardingCoordinator.swift new file mode 100644 index 0000000..698b196 --- /dev/null +++ b/Tiny/Features/Authentication/Views/OnboardingCoordinator.swift @@ -0,0 +1,51 @@ +// +// OnboardingCoordinator.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 27/11/25. +// + +import SwiftUI + +enum OnboardingStep { + case roleSelection + case nameInput(role: UserRole) + case roomCodeInput +} + +struct OnboardingCoordinator: View { + @EnvironmentObject var authService: AuthenticationService + @State private var currentStep: OnboardingStep = .roleSelection + @State private var selectedRole: UserRole? + + var body: some View { + Group { + switch currentStep { + case .roleSelection: + RoleSelectionView( + selectedRole: $selectedRole, + onContinue: { + if let role = selectedRole { + currentStep = .nameInput(role: role) + } + } + ) + case .nameInput(let role): + NameInputView( + selectedRole: role, + onContinue: { + if role == .father { + currentStep = .roomCodeInput + } + } + ) + case .roomCodeInput: + RoomCodeInputView() + } + } + } +} + +#Preview { + OnboardingCoordinator() +} diff --git a/Tiny/Features/Authentication/Views/RoleSelectionView.swift b/Tiny/Features/Authentication/Views/RoleSelectionView.swift new file mode 100644 index 0000000..f00146c --- /dev/null +++ b/Tiny/Features/Authentication/Views/RoleSelectionView.swift @@ -0,0 +1,92 @@ +// +// RoleSelectionView.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 26/11/25. +// + +import SwiftUI + +struct RoleSelectionView: View { + @EnvironmentObject var authService: AuthenticationService + @Binding var selectedRole: UserRole? + let onContinue: () -> Void + + var body: some View { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .clipped() + .ignoresSafeArea() + + VStack(spacing: 100) { + VStack(spacing: 12) { + Text("Get to Know You!") + .font(.title2.bold()) + Text("So… are you the super Mom or the awesome Dad? Let’s step in!") + .font(.body) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 56) + + HStack(spacing: 24) { + RoleButton( + title: "Mom", + icon: "tinyMom", + isSelected: selectedRole == .mother + ) { + withAnimation(.spring(duration: 0.3)) { + selectedRole = .mother + } + } + RoleButton( + title: "Dad", + icon: "tinyDad", + isSelected: selectedRole == .father + ) { + withAnimation(.spring(duration: 0.3)) { + selectedRole = .father + } + } + } + + continueButton + .padding(.horizontal, 40) + + } + } + } + + private var continueButton: some View { + let enabled = selectedRole != nil + + return Button( + action: { + guard enabled else { return } + onContinue() + }, + label: { + HStack { + Spacer() + Text("Continue") + .fontWeight(.semibold) + .frame(height: 48) + .padding(.horizontal, 56) + .glassEffect() + Spacer() + } + .contentShape(Rectangle()) + } + ) + .buttonStyle(.plain) + .disabled(selectedRole == nil) + .opacity(enabled ? 1.0 : 0.65) + .animation(.spring(response: 0.35, dampingFraction: 0.8), value: enabled) + } +} + +// #Preview { +// RoleSelectionView() +// .preferredColorScheme(.dark) +// } diff --git a/Tiny/Features/Authentication/Views/RoomCodeInputView.swift b/Tiny/Features/Authentication/Views/RoomCodeInputView.swift new file mode 100644 index 0000000..2f738c3 --- /dev/null +++ b/Tiny/Features/Authentication/Views/RoomCodeInputView.swift @@ -0,0 +1,132 @@ +// +// RoomCodeInputView.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 27/11/25. +// + +import SwiftUI + +struct RoomCodeInputView: View { + @EnvironmentObject var authService: AuthenticationService + + @State private var roomCode: String = "" + @State private var isLoading = false + @State private var errorMessage: String? + + func makeTitle() -> AttributedString { + var title = AttributedString("Together Starts Here") + + if let range = title.range(of: "Together") { + title[range].foregroundColor = Color("mainYellow") + } + + return title + } + + var body: some View { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .clipped() + .ignoresSafeArea() + + VStack(spacing: 100) { + VStack(spacing: 12) { + Text(makeTitle()) + .font(.title2.bold()) + Text("Enter the code from Mom to join your shared parent space.") + .font(.body) + .multilineTextAlignment(.center) + } + VStack(alignment: .center, spacing: 56) { + Image("tinyMom") + .resizable() + .frame(width: 126, height: 136) + TextField("Enter code", text: $roomCode) + .font(.body) + .multilineTextAlignment(.center) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(32) + .autocorrectionDisabled() + .textInputAutocapitalization(.characters) + .padding(.horizontal, 40) + if let error = errorMessage { + Text(error) + .foregroundColor(.red) + .font(.caption) + .padding() + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + } + VStack(spacing: 12) { + Button(action: handleJoinRoom) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Text("Join Room") + .fontWeight(.semibold) + .frame(height: 48) + .padding(.horizontal, 56) + .glassEffect() + + } + } + .buttonStyle(.plain) + .disabled(roomCode.trimmingCharacters(in: .whitespaces).isEmpty || isLoading) + Button(action: handleSkip) { + Text("Skip") + .fontWeight(.medium) + .foregroundStyle(Color(.systemGray)) + } + .buttonStyle(.plain) + .disabled(isLoading) + } + + } + } + } + + private func handleJoinRoom() { + isLoading = true + errorMessage = nil + + Task { + do { + let code = roomCode.trimmingCharacters(in: .whitespaces).uppercased() + + // First make sure the role is set to father + try await authService.updateUserRole(role: .father, roomCode: code) + + // Then join the room (this updates the room document) + try await authService.joinRoom(roomCode: code) + + print("✅ Successfully joined room: \(code)") + } catch { + errorMessage = error.localizedDescription + isLoading = false + } + // Don't set isLoading to false - let the navigation happen + } + } + + private func handleSkip() { + Task { + do { + // Just update the role without a room code + try await authService.updateUserRole(role: .father, roomCode: nil) + } catch { + errorMessage = error.localizedDescription + } + } + } +} + +#Preview { + RoomCodeInputView() + .preferredColorScheme(.dark) +} diff --git a/Tiny/Features/Authentication/Views/SignInView.swift b/Tiny/Features/Authentication/Views/SignInView.swift new file mode 100644 index 0000000..93c0da2 --- /dev/null +++ b/Tiny/Features/Authentication/Views/SignInView.swift @@ -0,0 +1,91 @@ +// +// SignInView.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 26/11/25. +// + +import SwiftUI +import AuthenticationServices + +struct SignInView: View { + @EnvironmentObject var authService: AuthenticationService + @State private var errorMessage: String? + + func makeTitle() -> AttributedString { + var title = AttributedString("Let's Begin!") + + if let range = title.range(of: "Begin") { + title[range].foregroundColor = Color("mainYellow") + } + + return title + } + + var body: some View { + ZStack(alignment: .center) { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .clipped() + .ignoresSafeArea() + + VStack(spacing: 100) { + VStack(spacing: 12) { + Text(makeTitle()) + .font(.title2.bold()) + Text("You can always find guides and info in your profile later.") + .font(.body) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 56) + + Image("TinyMascot_Book") + .resizable() + .frame(width: 244, height: 188) + + VStack { + if let error = errorMessage { + Text(error) + .foregroundColor(.red) + .font(.caption) + .padding() + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + .padding(.horizontal) + } + SignInWithAppleButton( + onRequest: { request in + request.requestedScopes = [.email, .fullName] + request.nonce = authService.startSignInWithAppleFlow() + }, + onCompletion: { result in + switch result { + case .success(let authorization): + Task { + do { + try await authService.signInWithApple(authorization: authorization) + } catch { + errorMessage = error.localizedDescription + } + } + case .failure(let error): + errorMessage = error.localizedDescription + } + } + ) + .signInWithAppleButtonStyle(.white) + .frame(height: 50) + .cornerRadius(12) + .padding(.horizontal, 40) + } + } + } + } +} + +#Preview { + SignInView() + .environmentObject(AuthenticationService()) + .preferredColorScheme(.dark) +} diff --git a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift index f8a73c3..802f230 100644 --- a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift +++ b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift @@ -13,13 +13,31 @@ class HeartbeatMainViewModel: ObservableObject { @Published var showTimeline = false let heartbeatSoundManager = HeartbeatSoundManager() - func setupManager(modelContext: ModelContext) { + func setupManager( + modelContext: ModelContext, + syncManager: HeartbeatSyncManager, + userId: String?, + roomCode: String?, + userRole: UserRole? + ) { heartbeatSoundManager.modelContext = modelContext + heartbeatSoundManager.syncManager = syncManager + heartbeatSoundManager.currentUserId = userId + heartbeatSoundManager.currentRoomCode = roomCode + heartbeatSoundManager.currentUserRole = userRole heartbeatSoundManager.loadFromSwiftData() } func handleRecordingSelection(_ recording: Recording) { + print("🎵 Recording selected: \(recording.fileURL.lastPathComponent)") + + // Set as last recording heartbeatSoundManager.lastRecording = recording + + // Play the recording + heartbeatSoundManager.togglePlayback(recording: recording) + + // Close timeline and go to orb view for playback (for both mother and father) withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { showTimeline = false } diff --git a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift index 40d2862..a0026c6 100644 --- a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift +++ b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift @@ -11,17 +11,30 @@ import SwiftData struct HeartbeatMainView: View { @StateObject private var viewModel = HeartbeatMainViewModel() @Environment(\.modelContext) private var modelContext + @EnvironmentObject var authService: AuthenticationService + @EnvironmentObject var syncManager: HeartbeatSyncManager + + @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 { + // Timeline view - accessible by both mom and dad if viewModel.showTimeline { PregnancyTimelineView( heartbeatSoundManager: viewModel.heartbeatSoundManager, showTimeline: $viewModel.showTimeline, - onSelectRecording: viewModel.handleRecordingSelection + onSelectRecording: viewModel.handleRecordingSelection, + isMother: isMother ) .transition(.opacity) } else { + // Orb view - for playback (both) and recording (mother only) OrbLiveListenView( heartbeatSoundManager: viewModel.heartbeatSoundManager, showTimeline: $viewModel.showTimeline @@ -29,9 +42,48 @@ struct HeartbeatMainView: View { .transition(.opacity) } } - .preferredColorScheme(.dark) - .onAppear { - viewModel.setupManager(modelContext: modelContext) + } + + private func initializeManager() { + Task { + // Auto-create room for mothers if they don't have one + if isMother && authService.currentUser?.roomCode == nil { + do { + let roomCode = try await authService.createRoom() + print("✅ Room created: \(roomCode)") + } catch { + print("❌ Error creating room: \(error)") + } + } + + // Wait a bit for room code to be set + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + + // Now setup the manager with current user data + let userId = authService.currentUser?.id + let roomCode = authService.currentUser?.roomCode + + print("🔍 Initializing manager with:") + print(" User ID: \(userId ?? "nil")") + print(" Room Code: \(roomCode ?? "nil")") + + await MainActor.run { + viewModel.setupManager( + modelContext: modelContext, + syncManager: syncManager, + userId: userId, + roomCode: roomCode, + userRole: authService.currentUser?.role + ) + isInitialized = true + } + + // For fathers, start in timeline view + if !isMother { + await MainActor.run { + viewModel.showTimeline = true + } + } } } } @@ -39,4 +91,6 @@ struct HeartbeatMainView: View { #Preview { HeartbeatMainView() .modelContainer(for: SavedHeartbeat.self, inMemory: true) + .environmentObject(AuthenticationService()) + .environmentObject(HeartbeatSyncManager()) } diff --git a/Tiny/Features/Profile/Models/UserProfileManager.swift b/Tiny/Features/Profile/Models/UserProfileManager.swift index 60a0209..6821bba 100644 --- a/Tiny/Features/Profile/Models/UserProfileManager.swift +++ b/Tiny/Features/Profile/Models/UserProfileManager.swift @@ -10,61 +10,61 @@ internal import Combine class UserProfileManager: ObservableObject { static let shared = UserProfileManager() - + @Published var profileImage: UIImage? @Published var userName: String = "Guest" @Published var userEmail: String? @Published var isSignedIn: Bool = false - + // Persistence Keys private let kUserName = "savedUserName" private let kUserEmail = "savedUserEmail" private let kIsSignedIn = "isUserSignedIn" - + private init() { loadUserData() } - + // MARK: - Data Persistence - + func loadUserData() { let defaults = UserDefaults.standard isSignedIn = defaults.bool(forKey: kIsSignedIn) - + if let savedName = defaults.string(forKey: kUserName) { userName = savedName } - + if let savedEmail = defaults.string(forKey: kUserEmail) { userEmail = savedEmail } - + loadProfileImageFromDisk() } - + func saveUserData() { let defaults = UserDefaults.standard defaults.set(isSignedIn, forKey: kIsSignedIn) defaults.set(userName, forKey: kUserName) defaults.set(userEmail, forKey: kUserEmail) } - + func saveProfileImage(_ image: UIImage?) { profileImage = image - + if let image = image { saveProfileImageToDisk(image) } else { deleteProfileImageFromDisk() } } - + private func saveProfileImageToDisk(_ image: UIImage) { guard let data = image.jpegData(compressionQuality: 0.8) else { return } let fileURL = getProfileImageURL() try? data.write(to: fileURL) } - + private func loadProfileImageFromDisk() { let fileURL = getProfileImageURL() guard let data = try? Data(contentsOf: fileURL), @@ -74,29 +74,29 @@ class UserProfileManager: ObservableObject { } profileImage = image } - + private func deleteProfileImageFromDisk() { let fileURL = getProfileImageURL() try? FileManager.default.removeItem(at: fileURL) } - + private func getProfileImageURL() -> URL { let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] return documentsPath.appendingPathComponent("profileImage.jpg") } - + // MARK: - Actions - + func signOut() { isSignedIn = false userName = "Guest" userEmail = nil profileImage = nil - + saveUserData() deleteProfileImageFromDisk() } - + func signInDummy() { isSignedIn = true userName = "John Doe" diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift index 0dd5d9a..7b8270d 100644 --- a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -13,9 +13,9 @@ class ProfileViewModel: ObservableObject { // Observe the singleton manager so this ViewModel publishes changes when manager changes var manager = UserProfileManager.shared private var cancellables = Set() - + @AppStorage("appTheme") var appTheme: String = "System" - + init() { // Propagate manager changes to this ViewModel manager.objectWillChange @@ -24,34 +24,27 @@ class ProfileViewModel: ObservableObject { } .store(in: &cancellables) } - + // MARK: - Computed Properties for View Bindings - + var isSignedIn: Bool { manager.isSignedIn } - + var userName: String { get { manager.userName } set { manager.userName = newValue } } - + var userEmail: String? { manager.userEmail } - + // For ImagePicker binding var profileImage: UIImage? { get { manager.profileImage } set { manager.saveProfileImage(newValue) } } - - // MARK: - Actions - - func saveName() { - manager.saveUserData() - print("Name saved: \(manager.userName)") - } func signIn() { manager.signInDummy() diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift index 3be6cfa..446f451 100644 --- a/Tiny/Features/Profile/Views/ProfileView.swift +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -10,6 +10,18 @@ import SwiftUI struct ProfileView: View { @StateObject private var viewModel = ProfileViewModel() @State private var showingSignOutConfirmation = false + @Environment(\.modelContext) private var modelContext + @EnvironmentObject var authService: AuthenticationService + @EnvironmentObject var syncManager: HeartbeatSyncManager + @StateObject private var heartbeatMainViewModel = HeartbeatMainViewModel() + + @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 { @@ -32,6 +44,25 @@ struct ProfileView: View { settingsList } } + .onAppear { + // Initialize only once + if !isInitialized { + initializeManager() + } + } + .onChange(of: authService.currentUser?.roomCode) { oldValue, newValue in + // Re-initialize when room code changes + if newValue != nil && newValue != oldValue { + print("🔄 Room code updated: \(newValue ?? "nil")") + initializeManager() + } + } + .sheet(isPresented: $showRoomCode) { + RoomCodeDisplayView() + .environmentObject(authService) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } } // MARK: - View Components @@ -51,7 +82,7 @@ struct ProfileView: View { } .buttonStyle(.plain) - Text(viewModel.isSignedIn ? viewModel.userName : "Guest") + Text(authService.currentUser?.name ?? "Guest") .font(.title2) .fontWeight(.semibold) .foregroundStyle(.white) @@ -194,10 +225,14 @@ struct ProfileView: View { Spacer() - Text("Connect with Your Partner") - .font(.subheadline) - .foregroundColor(.blue) - .fontWeight(.medium) + Button(action: { + showRoomCode.toggle() + }, label: { + Text("Connect with Your Partner") + .font(.subheadline) + .foregroundColor(.blue) + .fontWeight(.medium) + }) Spacer() } @@ -238,9 +273,54 @@ struct ProfileView: View { .background(Color("rowProfileGrey")) .cornerRadius(14) } + + private func initializeManager() { + Task { + // Auto-create room for mothers if they don't have one + if isMother && authService.currentUser?.roomCode == nil { + do { + let roomCode = try await authService.createRoom() + print("✅ Room created: \(roomCode)") + } catch { + print("❌ Error creating room: \(error)") + } + } + + // Wait a bit for room code to be set + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + + // Now setup the manager with current user data + let userId = authService.currentUser?.id + let roomCode = authService.currentUser?.roomCode + + print("🔍 Initializing manager with:") + print(" User ID: \(userId ?? "nil")") + print(" Room Code: \(roomCode ?? "nil")") + + await MainActor.run { + heartbeatMainViewModel.setupManager( + modelContext: modelContext, + syncManager: syncManager, + userId: userId, + roomCode: roomCode, + userRole: authService.currentUser?.role + ) + isInitialized = true + } + + // For fathers, start in timeline view + if !isMother { + await MainActor.run { + heartbeatMainViewModel.showTimeline = true + } + } + } + } + } struct ProfilePhotoDetailView: View { + @EnvironmentObject var authService: AuthenticationService @ObservedObject var viewModel: ProfileViewModel @State private var showingImagePicker = false @State private var showingCamera = false @@ -270,15 +350,16 @@ struct ProfilePhotoDetailView: View { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { - viewModel.userName = tempUserName - viewModel.saveName() - dismiss() + Task { + try? await authService.updateUserName(name: tempUserName) + dismiss() + } } .disabled(tempUserName.trimmingCharacters(in: .whitespaces).isEmpty) } } .onAppear { - tempUserName = viewModel.userName + tempUserName = authService.currentUser?.name ?? "" } .sheet(isPresented: $showingPhotoOptions) { BottomPhotoPickerSheet( @@ -465,5 +546,7 @@ struct TutorialDummy: View { #Preview { ProfileView() + .environmentObject(AuthenticationService()) // <-- mock + .environmentObject(HeartbeatSyncManager()) .preferredColorScheme(.dark) } diff --git a/Tiny/Features/Room/Views/RoomCodeDisplayView.swift b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift new file mode 100644 index 0000000..d4bf99b --- /dev/null +++ b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift @@ -0,0 +1,137 @@ +// +// RoomCodeDisplayView.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 27/11/25. +// + +import SwiftUI + +struct RoomCodeDisplayView: View { + @EnvironmentObject var authService: AuthenticationService + @Environment(\.dismiss) var dismiss + @State private var showCopiedMessage = false + + var body: some View { + ZStack { + Color(.systemBackground) + .ignoresSafeArea() + + VStack(spacing: 30) { + // Header + HStack { + Spacer() + Button(action: { + dismiss() + }, label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.secondary) + }) + } + .padding(.horizontal) + .padding(.top, 10) + + Spacer() + + // Icon + Image(systemName: "person.2.circle.fill") + .font(.system(size: 80)) + .foregroundColor(.pink) + + // Title + VStack(spacing: 10) { + Text(authService.currentUser?.role == .mother ? "Your Room Code" : "Room Code") + .font(.title2) + .fontWeight(.bold) + + Text(authService.currentUser?.role == .mother ? + "Share this code with your partner" : + "You're connected to this room") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + + // Room Code Display + if let roomCode = authService.currentUser?.roomCode { + VStack(spacing: 15) { + Text(roomCode) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .tracking(8) + .foregroundColor(.primary) + .padding(.vertical, 20) + .padding(.horizontal, 40) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(.systemGray6)) + ) + + // Copy Button + Button(action: copyRoomCode) { + HStack(spacing: 8) { + Image(systemName: showCopiedMessage ? "checkmark" : "doc.on.doc") + .font(.system(size: 16)) + Text(showCopiedMessage ? "Copied!" : "Copy Code") + .fontWeight(.medium) + } + .foregroundColor(showCopiedMessage ? .green : .blue) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(showCopiedMessage ? Color.green.opacity(0.1) : Color.blue.opacity(0.1)) + ) + } + } + } else { + VStack(spacing: 15) { + if authService.currentUser?.role == .mother { + ProgressView() + .padding() + Text("Creating room...") + .font(.subheadline) + .foregroundColor(.secondary) + } else { + Text("No room code") + .font(.subheadline) + .foregroundColor(.secondary) + + Text("Ask your partner for their room code") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + } + } + + Spacer() + Spacer() + } + } + } + + private func copyRoomCode() { + if let roomCode = authService.currentUser?.roomCode { + UIPasteboard.general.string = roomCode + + withAnimation { + showCopiedMessage = true + } + + // Reset after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + showCopiedMessage = false + } + } + } + } +} + +#Preview { + RoomCodeDisplayView() + .environmentObject(AuthenticationService()) +} diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index bfb44f5..739c6ef 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -9,46 +9,57 @@ import SwiftUI struct PregnancyTimelineView: View { @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager - // ⬇️ NEW: Binding to control close @Binding var showTimeline: Bool let onSelectRecording: (Recording) -> Void - + let isMother: Bool // Add this parameter + @Namespace private var animation @State private var selectedWeek: WeekSection? @State private var groupedData: [WeekSection] = [] - + @ObservedObject private var userProfile = UserProfileManager.shared - + var body: some View { NavigationStack { ZStack { - LinearGradient(colors: [Color(red: 0.05, green: 0.05, blue: 0.15), Color.black], startPoint: .top, endPoint: .bottom) - .ignoresSafeArea() - - ZStack { - if let week = selectedWeek { - TimelineDetailView(week: week, animation: animation, onSelectRecording: onSelectRecording) - .transition(.opacity) - } else { - MainTimelineListView(groupedData: groupedData, selectedWeek: $selectedWeek, animation: animation) - .transition(.opacity) - } + if let week = selectedWeek { + TimelineDetailView( + week: week, + animation: animation, + onSelectRecording: onSelectRecording, + isMother: isMother // Pass it here + ) + .transition(.opacity) + } else { + MainTimelineListView(groupedData: groupedData, selectedWeek: $selectedWeek, animation: animation) + .transition(.opacity) } - + navigationButtons } - .onAppear(perform: groupRecordings) + .onAppear { + print("📱 Timeline appeared - grouping recordings") + groupRecordings() + } + .onChange(of: heartbeatSoundManager.savedRecordings) { oldValue, newValue in + print("🔄 Recordings changed: \(oldValue.count) -> \(newValue.count)") + groupRecordings() + } } } - + private var navigationButtons: some View { VStack { // Top Bar HStack { + + // LEFT SIDE if selectedWeek != nil { // Back Button (Detail -> List) Button { - withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { selectedWeek = nil } + withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { + selectedWeek = nil + } } label: { Image(systemName: "chevron.left") .font(.system(size: 20, weight: .bold)) @@ -58,14 +69,18 @@ struct PregnancyTimelineView: View { } .glassEffect(.clear) .matchedGeometryEffect(id: "navButton", in: animation) + } else { - Spacer() + + Spacer(minLength: 0) } - + Spacer() - - // Profile Button (Top Right) + + // RIGHT SIDE if selectedWeek == nil { + + // Profile Button NavigationLink { ProfileView() } label: { @@ -78,12 +93,15 @@ struct PregnancyTimelineView: View { Image(systemName: "person.crop.circle.fill") .resizable() .scaledToFit() - .foregroundColor(.white.opacity(0.8)) + .foregroundStyle(.white.opacity(0.8)) } } - .frame(width: 45, height: 45 ) + .frame(width: 45, height: 45) .clipShape(Circle()) - .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + .overlay( + Circle() + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) } } @@ -92,15 +110,19 @@ struct PregnancyTimelineView: View { .padding(.top, 20) Spacer() - + + // Bottom Close Button (Only on List Screen) if selectedWeek == nil { - // Book Button (List -> Close to Orb) Button { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { showTimeline = false } } label: { - Image(systemName: "book.fill").font(.system(size: 28)).foregroundColor(.white).frame(width: 77, height: 77).clipShape(Circle()) + Image(systemName: "book.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + .frame(width: 77, height: 77) + .clipShape(Circle()) } .glassEffect(.clear) .matchedGeometryEffect(id: "navButton", in: animation) @@ -109,48 +131,24 @@ struct PregnancyTimelineView: View { } .ignoresSafeArea(.all, edges: .bottom) } - + private func groupRecordings() { let raw = heartbeatSoundManager.savedRecordings + print("📊 Grouping \(raw.count) recordings") + let calendar = Calendar.current - + let grouped = Dictionary(grouping: raw) { recording -> Int in return calendar.component(.weekOfYear, from: recording.createdAt) } - + self.groupedData = grouped.map { WeekSection(weekNumber: $0.key, recordings: $0.value.sorted(by: { $0.createdAt > $1.createdAt })) }.sorted(by: { $0.weekNumber < $1.weekNumber }) - } -} -#Preview { - let mockManager = HeartbeatSoundManager() - - let now = Date() - let week1Date = now - let week2Date = Calendar.current.date(byAdding: .day, value: -7, to: now)! // 1 week ago - let week3Date = Calendar.current.date(byAdding: .day, value: -21, to: now)! // 3 weeks ago - - mockManager.savedRecordings = [ - // Week A (Current Week) - Recording(fileURL: URL(fileURLWithPath: "my-baby-heartbeat.caf"), createdAt: week1Date), - Recording(fileURL: URL(fileURLWithPath: "morning-check.caf"), createdAt: week1Date.addingTimeInterval(-100)), - - // Week B (Last Week) - Recording(fileURL: URL(fileURLWithPath: "late-night-kick.caf"), createdAt: week2Date), - - // Week C (3 Weeks Ago) - Recording(fileURL: URL(fileURLWithPath: "first-time.caf"), createdAt: week3Date), - Recording(fileURL: URL(fileURLWithPath: "doctor-visit.caf"), createdAt: week3Date.addingTimeInterval(-50)) - ] - - return PregnancyTimelineView( - heartbeatSoundManager: mockManager, - showTimeline: .constant(true), - onSelectRecording: { recording in - print("Selected: \(recording.fileURL.lastPathComponent)") + print("📊 Created \(groupedData.count) week sections") + for section in groupedData { + print(" Week \(section.weekNumber): \(section.recordings.count) recordings") } - ) - .preferredColorScheme(.dark) + } } diff --git a/Tiny/Features/Timeline/Views/TimelineDetailView.swift b/Tiny/Features/Timeline/Views/TimelineDetailView.swift index a16610d..8812f4e 100644 --- a/Tiny/Features/Timeline/Views/TimelineDetailView.swift +++ b/Tiny/Features/Timeline/Views/TimelineDetailView.swift @@ -12,6 +12,8 @@ struct TimelineDetailView: View { var animation: Namespace.ID // Passed from parent let onSelectRecording: (Recording) -> Void + let isMother: Bool + var body: some View { GeometryReader { geometry in VStack(spacing: 0) { @@ -129,49 +131,3 @@ struct TimelineDetailView: View { return raw } } - -#Preview { - struct PreviewWrapper: View { - @Namespace var animation - - let mockWeek = WeekSection( - weekNumber: 24, - recordings: [ - Recording( - fileURL: URL(fileURLWithPath: "morning-kick.caf"), - createdAt: Date() - ), - Recording( - fileURL: URL(fileURLWithPath: "hiccups.caf"), - createdAt: Date().addingTimeInterval(-3600) // 1 hour ago - ), - Recording( - fileURL: URL(fileURLWithPath: "bedtime.caf"), - createdAt: Date().addingTimeInterval(-7200) // 2 hours ago - ) - ] - ) - - var body: some View { - ZStack { - LinearGradient( - colors: [Color(red: 0.05, green: 0.05, blue: 0.15), Color.black], - startPoint: .top, - endPoint: .bottom - ) - .ignoresSafeArea() - - TimelineDetailView( - week: mockWeek, - animation: animation, - onSelectRecording: { recording in - print("Selected: \(recording.fileURL.lastPathComponent)") - } - ) - } - } - } - - return PreviewWrapper() - .preferredColorScheme(.dark) -} diff --git a/Tiny/GoogleService-Info.plist b/Tiny/GoogleService-Info.plist new file mode 100644 index 0000000..5a5b3eb --- /dev/null +++ b/Tiny/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyCmVZHj28vHilVLE7-VOaAjngaBEW1Ni80 + GCM_SENDER_ID + 644528977422 + PLIST_VERSION + 1 + BUNDLE_ID + com.miracle.tiny + PROJECT_ID + tinyapp-by-miracle + STORAGE_BUCKET + tinyapp-by-miracle.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:644528977422:ios:42560f4373f09945caae5f + + \ No newline at end of file diff --git a/Tiny/Resources/Assets.xcassets/TinyMascot_Book.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/TinyMascot_Book.imageset/Contents.json new file mode 100644 index 0000000..dd6860c --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/TinyMascot_Book.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "TinyMascot_Book.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/TinyMascot_Book.imageset/TinyMascot_Book.png b/Tiny/Resources/Assets.xcassets/TinyMascot_Book.imageset/TinyMascot_Book.png new file mode 100644 index 0000000..42c1aac Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/TinyMascot_Book.imageset/TinyMascot_Book.png differ diff --git a/Tiny/Resources/Assets.xcassets/tinyDad.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/tinyDad.imageset/Contents.json new file mode 100644 index 0000000..0f0bc04 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/tinyDad.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tinyDad.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/tinyDad.imageset/tinyDad.png b/Tiny/Resources/Assets.xcassets/tinyDad.imageset/tinyDad.png new file mode 100644 index 0000000..8686852 Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/tinyDad.imageset/tinyDad.png differ diff --git a/Tiny/Resources/Assets.xcassets/tinyMom.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/tinyMom.imageset/Contents.json new file mode 100644 index 0000000..e3d3383 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/tinyMom.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tinyMom.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/tinyMom.imageset/tinyMom.png b/Tiny/Resources/Assets.xcassets/tinyMom.imageset/tinyMom.png new file mode 100644 index 0000000..590c5b8 Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/tinyMom.imageset/tinyMom.png differ diff --git a/Tiny/Resources/Assets.xcassets/tinyViolet.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/tinyViolet.colorset/Contents.json new file mode 100644 index 0000000..7d97c94 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/tinyViolet.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0x95", + "red" : "0x95" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0x95", + "red" : "0x95" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index 0e4e607..39f4a6e 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -73,7 +73,7 @@ "isCommentAutoGenerated" : true }, "20" : { - "comment" : "The number \"20\" displayed in the \"Pregnancy Age\" feature card.", + "comment" : "The number of weeks in a pregnancy.", "isCommentAutoGenerated" : true }, "20Hz" : { @@ -111,6 +111,10 @@ "comment" : "A button that applies a spatial audio preset.", "isCommentAutoGenerated" : true }, + "Ask your partner for their room code" : { + "comment" : "A description below the \"Copy Code\" button, explaining that they can request it from you.", + "isCommentAutoGenerated" : true + }, "Audio Analysis" : { "comment" : "A section header for the audio analysis part of the view.", "isCommentAutoGenerated" : true @@ -151,7 +155,7 @@ "isCommentAutoGenerated" : true }, "Change Profile Photo" : { - "comment" : "A title for the bottom sheet that appears when the user taps on their profile photo.", + "comment" : "A title for the bottom sheet that appears when a user wants to change their profile photo.", "isCommentAutoGenerated" : true }, "Confidence" : { @@ -163,7 +167,7 @@ "isCommentAutoGenerated" : true }, "Connect with Your Partner" : { - "comment" : "A call-to-action text that encourages users to connect with their partners.", + "comment" : "A call-to-action text displayed below the feature card.", "isCommentAutoGenerated" : true }, "Connect your AirPods and let Tiny access your microphone to hear every little beat." : { @@ -182,6 +186,18 @@ "comment" : "A button label that says \"Continue\" when a user pauses listening to a podcast.", "isCommentAutoGenerated" : true }, + "Copied!" : { + "comment" : "A confirmation message displayed when the room code is successfully copied to the clipboard.", + "isCommentAutoGenerated" : true + }, + "Copy Code" : { + "comment" : "A button label that says \"Copy Code\".", + "isCommentAutoGenerated" : true + }, + "Creating room..." : { + "comment" : "A message displayed while a room is being created.", + "isCommentAutoGenerated" : true + }, "Current Status" : { "comment" : "The title of the section that displays the user's current heartbeat status.", "isCommentAutoGenerated" : true @@ -199,7 +215,7 @@ "isCommentAutoGenerated" : true }, "Dummy Theme View" : { - "comment" : "A placeholder view representing a theme-related screen.", + "comment" : "A placeholder view for a theme-related feature.", "isCommentAutoGenerated" : true }, "Dummy Tutorial View" : { @@ -207,12 +223,19 @@ "isCommentAutoGenerated" : true }, "Edit Profile" : { - "comment" : "The title of the view that allows users to edit their profile information.", + "comment" : "The title of the view that allows users to edit their profile.", "isCommentAutoGenerated" : true }, "Enhanced proximity audio" : { "comment" : "A description of the spatial audio mode.", "isCommentAutoGenerated" : true + }, + "Enter code" : { + "comment" : "A label for the text field where the user enters the room code.", + "isCommentAutoGenerated" : true + }, + "Enter the code from Mom to join your shared parent space." : { + }, "Enter your name" : { "comment" : "A placeholder text for a text field where a user can enter their name.", @@ -247,6 +270,10 @@ "comment" : "A title for the frequency spectrum visualization.", "isCommentAutoGenerated" : true }, + "Get to Know You!" : { + "comment" : "A title displayed at the top of the screen in the RoleSelectionView.", + "isCommentAutoGenerated" : true + }, "Hear Baby's Heartbeat" : { "comment" : "The title of the initial tutorial view.", "isCommentAutoGenerated" : true @@ -283,6 +310,10 @@ "comment" : "A label describing the number of high-quality heartbeat detections.", "isCommentAutoGenerated" : true }, + "Hold sphere to stop session" : { + "comment" : "A text displayed when a user is holding the orb to stop a live listening session.", + "isCommentAutoGenerated" : true + }, "Hold then drag the sphere" : { "comment" : "A description of how to save or delete a recording.", "isCommentAutoGenerated" : true @@ -291,6 +322,10 @@ "comment" : "A label displayed in the center of the countdown view, instructing the user to hold their finger to stop the countdown.", "isCommentAutoGenerated" : true }, + "I can call you..." : { + "comment" : "A placeholder text for a text field where the user can input their name.", + "isCommentAutoGenerated" : true + }, "Insufficient data for chart" : { "comment" : "A message displayed when there is not enough data to generate a chart.", "isCommentAutoGenerated" : true @@ -299,6 +334,10 @@ "comment" : "A message displayed when there is insufficient data to perform trend analysis on the user's heartbeat data.", "isCommentAutoGenerated" : true }, + "Join Room" : { + "comment" : "A button label that says \"Join Room\".", + "isCommentAutoGenerated" : true + }, "Key" : { "extractionState" : "manual" }, @@ -344,6 +383,10 @@ }, "No recording available. Record in Orb mode first." : { + }, + "No room code" : { + "comment" : "A message displayed when a user is not in a room, explaining that they should ask their partner for the room code.", + "isCommentAutoGenerated" : true }, "Noise Gate" : { "comment" : "A setting that controls the sensitivity of the noise gate.", @@ -395,7 +438,7 @@ "isCommentAutoGenerated" : true }, "Privacy Policy" : { - "comment" : "A label for a privacy policy option in the profile settings.", + "comment" : "A label for the privacy policy option in the profile settings.", "isCommentAutoGenerated" : true }, "Proximity Gain" : { @@ -421,6 +464,10 @@ "comment" : "A title for a section that explains how to replay a user's recording.", "isCommentAutoGenerated" : true }, + "Room Code" : { + "comment" : "A label describing the code that uniquely identifies a room.", + "isCommentAutoGenerated" : true + }, "S1 (Lub) Average" : { "comment" : "A label for the average of the \"Lub\" component of the S1 heartbeat sound.", "isCommentAutoGenerated" : true @@ -450,7 +497,7 @@ "isCommentAutoGenerated" : true }, "Save" : { - "comment" : "A button to save changes made to the user's profile.", + "comment" : "The text of a button that saves changes made in the profile.", "isCommentAutoGenerated" : true }, "Save or Delete" : { @@ -473,6 +520,10 @@ "comment" : "A button that triggers the sharing of a heartbeat data analysis report.", "isCommentAutoGenerated" : true }, + "Share this code with your partner" : { + "comment" : "A description under the \"Your Room Code\" title, instructing the user to share their room code with their partner.", + "isCommentAutoGenerated" : true + }, "Sign in with Apple" : { "comment" : "A button label that says \"Sign in with Apple\".", "isCommentAutoGenerated" : true @@ -505,6 +556,14 @@ "comment" : "A label displayed next to the left end of a slider in the \"Noise Gate\" section of the Enhanced Live Listen view.", "isCommentAutoGenerated" : true }, + "Skip" : { + "comment" : "A button label that allows the user to skip the room code entry process.", + "isCommentAutoGenerated" : true + }, + "So… are you the super Mom or the awesome Dad? Let’s step in!" : { + "comment" : "A description under the title \"Get to Know You!\" that explains the purpose of the view.", + "isCommentAutoGenerated" : true + }, "Spatial Mode" : { "comment" : "A label displayed next to a toggle switch.", "isCommentAutoGenerated" : true @@ -567,9 +626,13 @@ }, "Tap twice" : { + }, + "Tell me your name and step into your amazing journey" : { + "comment" : "A description below the text field asking the user to input their name.", + "isCommentAutoGenerated" : true }, "Terms and Conditions" : { - "comment" : "A link to the terms and conditions of the app.", + "comment" : "A link to the app's terms and conditions.", "isCommentAutoGenerated" : true }, "Theme" : { @@ -601,7 +664,11 @@ "isCommentAutoGenerated" : true }, "Weeks" : { - "comment" : "A unit of measurement for pregnancy weeks.", + "comment" : "A unit of measurement for weeks.", + "isCommentAutoGenerated" : true + }, + "You can always find guides and info in your profile later." : { + "comment" : "A description below the \"Let's Begin!\" title, explaining that users can find more information in their profiles later.", "isCommentAutoGenerated" : true }, "You can listen to your baby's heartbeat live and record it to listen again later." : { @@ -612,9 +679,17 @@ "comment" : "A message displayed in the confirmation dialog when signing out.", "isCommentAutoGenerated" : true }, + "You're connected to this room" : { + "comment" : "A description below a code display, indicating that they are connected to a room.", + "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 + }, + "Your Room Code" : { + "comment" : "A label displayed above the user's room code.", + "isCommentAutoGenerated" : true } }, "version" : "1.1" diff --git a/Tiny/Tiny.entitlements b/Tiny/Tiny.entitlements new file mode 100644 index 0000000..80b5221 --- /dev/null +++ b/Tiny/Tiny.entitlements @@ -0,0 +1,12 @@ + + + + + aps-environment + development + com.apple.developer.applesignin + + Default + + +