diff --git a/Tiny.xcodeproj/project.pbxproj b/Tiny.xcodeproj/project.pbxproj index 97285b3..bded6f6 100644 --- a/Tiny.xcodeproj/project.pbxproj +++ b/Tiny.xcodeproj/project.pbxproj @@ -7,6 +7,30 @@ objects = { /* Begin PBXBuildFile section */ + 274B0DBC2ED9B59500825816 /* FirebaseAI in Frameworks */ = {isa = PBXBuildFile; productRef = 274B0DBB2ED9B59500825816 /* FirebaseAI */; }; + 274B0DBE2ED9B59500825816 /* FirebaseAILogic in Frameworks */ = {isa = PBXBuildFile; productRef = 274B0DBD2ED9B59500825816 /* FirebaseAILogic */; }; + 274B0DC02ED9B59500825816 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 274B0DBF2ED9B59500825816 /* FirebaseAnalytics */; }; + 274B0DC22ED9B59500825816 /* FirebaseAnalyticsCore in Frameworks */ = {isa = PBXBuildFile; productRef = 274B0DC12ED9B59500825816 /* FirebaseAnalyticsCore */; }; + 274B0DC42ED9B59500825816 /* FirebaseAnalyticsIdentitySupport in Frameworks */ = {isa = PBXBuildFile; productRef = 274B0DC32ED9B59500825816 /* FirebaseAnalyticsIdentitySupport */; }; + 277D766B2ED9E777008C8404 /* FirebaseAppCheck in Frameworks */ = {isa = PBXBuildFile; productRef = 277D766A2ED9E777008C8404 /* FirebaseAppCheck */; }; + 277D766D2ED9E777008C8404 /* FirebaseAppDistribution-Beta in Frameworks */ = {isa = PBXBuildFile; productRef = 277D766C2ED9E777008C8404 /* FirebaseAppDistribution-Beta */; }; + 277D766F2ED9E777008C8404 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 277D766E2ED9E777008C8404 /* FirebaseAuth */; }; + 277D76712ED9E777008C8404 /* FirebaseAuthCombine-Community in Frameworks */ = {isa = PBXBuildFile; productRef = 277D76702ED9E777008C8404 /* FirebaseAuthCombine-Community */; }; + 277D76732ED9E777008C8404 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 277D76722ED9E777008C8404 /* FirebaseCore */; }; + 277D76752ED9E777008C8404 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 277D76742ED9E777008C8404 /* FirebaseCrashlytics */; }; + 277D76772ED9E777008C8404 /* FirebaseDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = 277D76762ED9E777008C8404 /* FirebaseDatabase */; }; + 277D76792ED9E777008C8404 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 277D76782ED9E777008C8404 /* FirebaseFirestore */; }; + 277D767B2ED9E777008C8404 /* FirebaseFirestoreCombine-Community in Frameworks */ = {isa = PBXBuildFile; productRef = 277D767A2ED9E777008C8404 /* FirebaseFirestoreCombine-Community */; }; + 277D767D2ED9E777008C8404 /* FirebaseFunctions in Frameworks */ = {isa = PBXBuildFile; productRef = 277D767C2ED9E777008C8404 /* FirebaseFunctions */; }; + 277D767F2ED9E777008C8404 /* FirebaseFunctionsCombine-Community in Frameworks */ = {isa = PBXBuildFile; productRef = 277D767E2ED9E777008C8404 /* FirebaseFunctionsCombine-Community */; }; + 277D76812ED9E777008C8404 /* FirebaseInAppMessaging-Beta in Frameworks */ = {isa = PBXBuildFile; productRef = 277D76802ED9E777008C8404 /* FirebaseInAppMessaging-Beta */; }; + 277D76832ED9E777008C8404 /* FirebaseInstallations in Frameworks */ = {isa = PBXBuildFile; productRef = 277D76822ED9E777008C8404 /* FirebaseInstallations */; }; + 277D76852ED9E777008C8404 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 277D76842ED9E777008C8404 /* FirebaseMessaging */; }; + 277D76872ED9E777008C8404 /* FirebaseMLModelDownloader in Frameworks */ = {isa = PBXBuildFile; productRef = 277D76862ED9E777008C8404 /* FirebaseMLModelDownloader */; }; + 277D76892ED9E777008C8404 /* FirebasePerformance in Frameworks */ = {isa = PBXBuildFile; productRef = 277D76882ED9E777008C8404 /* FirebasePerformance */; }; + 277D768B2ED9E777008C8404 /* FirebaseRemoteConfig in Frameworks */ = {isa = PBXBuildFile; productRef = 277D768A2ED9E777008C8404 /* FirebaseRemoteConfig */; }; + 277D768D2ED9E777008C8404 /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 277D768C2ED9E777008C8404 /* FirebaseStorage */; }; + 277D768F2ED9E777008C8404 /* FirebaseStorageCombine-Community in Frameworks */ = {isa = PBXBuildFile; productRef = 277D768E2ED9E777008C8404 /* FirebaseStorageCombine-Community */; }; 430725D62EC5C791006234D7 /* Orb in Frameworks */ = {isa = PBXBuildFile; productRef = 430725D52EC5C791006234D7 /* Orb */; }; DE5E9C862EB354B200485B15 /* AudioKit in Frameworks */ = {isa = PBXBuildFile; productRef = DE5E9C852EB354B200485B15 /* AudioKit */; }; DE5E9C882EB354B700485B15 /* AudioKitEX in Frameworks */ = {isa = PBXBuildFile; productRef = DE5E9C872EB354B700485B15 /* AudioKitEX */; }; @@ -74,9 +98,33 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 277D76772ED9E777008C8404 /* FirebaseDatabase in Frameworks */, + 274B0DC42ED9B59500825816 /* FirebaseAnalyticsIdentitySupport in Frameworks */, 430725D62EC5C791006234D7 /* Orb in Frameworks */, + 277D767D2ED9E777008C8404 /* FirebaseFunctions in Frameworks */, + 274B0DBC2ED9B59500825816 /* FirebaseAI in Frameworks */, + 277D76752ED9E777008C8404 /* FirebaseCrashlytics in Frameworks */, + 277D76792ED9E777008C8404 /* FirebaseFirestore in Frameworks */, + 274B0DC02ED9B59500825816 /* FirebaseAnalytics in Frameworks */, DE5E9C862EB354B200485B15 /* AudioKit in Frameworks */, + 277D76872ED9E777008C8404 /* FirebaseMLModelDownloader in Frameworks */, DE5E9C882EB354B700485B15 /* AudioKitEX in Frameworks */, + 274B0DC22ED9B59500825816 /* FirebaseAnalyticsCore in Frameworks */, + 277D767F2ED9E777008C8404 /* FirebaseFunctionsCombine-Community in Frameworks */, + 277D76712ED9E777008C8404 /* FirebaseAuthCombine-Community in Frameworks */, + 277D76892ED9E777008C8404 /* FirebasePerformance in Frameworks */, + 277D767B2ED9E777008C8404 /* FirebaseFirestoreCombine-Community in Frameworks */, + 277D768F2ED9E777008C8404 /* FirebaseStorageCombine-Community in Frameworks */, + 277D76732ED9E777008C8404 /* FirebaseCore in Frameworks */, + 277D76832ED9E777008C8404 /* FirebaseInstallations in Frameworks */, + 277D766F2ED9E777008C8404 /* FirebaseAuth in Frameworks */, + 277D768B2ED9E777008C8404 /* FirebaseRemoteConfig in Frameworks */, + 277D766D2ED9E777008C8404 /* FirebaseAppDistribution-Beta in Frameworks */, + 277D768D2ED9E777008C8404 /* FirebaseStorage in Frameworks */, + 277D766B2ED9E777008C8404 /* FirebaseAppCheck in Frameworks */, + 274B0DBE2ED9B59500825816 /* FirebaseAILogic in Frameworks */, + 277D76852ED9E777008C8404 /* FirebaseMessaging in Frameworks */, + 277D76812ED9E777008C8404 /* FirebaseInAppMessaging-Beta in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -152,6 +200,30 @@ DE5E9C852EB354B200485B15 /* AudioKit */, DE5E9C872EB354B700485B15 /* AudioKitEX */, 430725D52EC5C791006234D7 /* Orb */, + 274B0DBB2ED9B59500825816 /* FirebaseAI */, + 274B0DBD2ED9B59500825816 /* FirebaseAILogic */, + 274B0DBF2ED9B59500825816 /* FirebaseAnalytics */, + 274B0DC12ED9B59500825816 /* FirebaseAnalyticsCore */, + 274B0DC32ED9B59500825816 /* FirebaseAnalyticsIdentitySupport */, + 277D766A2ED9E777008C8404 /* FirebaseAppCheck */, + 277D766C2ED9E777008C8404 /* FirebaseAppDistribution-Beta */, + 277D766E2ED9E777008C8404 /* FirebaseAuth */, + 277D76702ED9E777008C8404 /* FirebaseAuthCombine-Community */, + 277D76722ED9E777008C8404 /* FirebaseCore */, + 277D76742ED9E777008C8404 /* FirebaseCrashlytics */, + 277D76762ED9E777008C8404 /* FirebaseDatabase */, + 277D76782ED9E777008C8404 /* FirebaseFirestore */, + 277D767A2ED9E777008C8404 /* FirebaseFirestoreCombine-Community */, + 277D767C2ED9E777008C8404 /* FirebaseFunctions */, + 277D767E2ED9E777008C8404 /* FirebaseFunctionsCombine-Community */, + 277D76802ED9E777008C8404 /* FirebaseInAppMessaging-Beta */, + 277D76822ED9E777008C8404 /* FirebaseInstallations */, + 277D76842ED9E777008C8404 /* FirebaseMessaging */, + 277D76862ED9E777008C8404 /* FirebaseMLModelDownloader */, + 277D76882ED9E777008C8404 /* FirebasePerformance */, + 277D768A2ED9E777008C8404 /* FirebaseRemoteConfig */, + 277D768C2ED9E777008C8404 /* FirebaseStorage */, + 277D768E2ED9E777008C8404 /* FirebaseStorageCombine-Community */, ); productName = tiny; productReference = 433435C42EACD4E4004C6B8C /* Tiny.app */; @@ -240,6 +312,7 @@ DEF686412EB33FA9004CC0C3 /* XCRemoteSwiftPackageReference "AudioKit" */, DEF686422EB33FB2004CC0C3 /* XCRemoteSwiftPackageReference "AudioKitEX" */, 430725D42EC5C69D006234D7 /* XCRemoteSwiftPackageReference "Orb" */, + 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); preferredProjectObjectVersion = 77; productRefGroup = 433435C52EACD4E4004C6B8C /* Products */; @@ -465,15 +538,22 @@ 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 = "Apple Development"; + "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; INFOPLIST_KEY_CFBundleDisplayName = Tiny; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSCameraUsageDescription = "Allow Tiny to use camera for taking profile picture"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Tiny needs to use the microphone to capture heartbeat sounds."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Allow Tiny to access photos library"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -487,6 +567,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; @@ -506,15 +588,22 @@ 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 = "Apple Development"; + "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; INFOPLIST_KEY_CFBundleDisplayName = Tiny; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSCameraUsageDescription = "Allow Tiny to use camera for taking profile picture"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Tiny needs to use the microphone to capture heartbeat sounds."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Allow Tiny to access photos library"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -528,6 +617,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; @@ -548,7 +639,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6PN64DQKLX; + DEVELOPMENT_TEAM = LM2TBH9R6C; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; @@ -570,7 +661,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6PN64DQKLX; + DEVELOPMENT_TEAM = LM2TBH9R6C; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; @@ -591,7 +682,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6PN64DQKLX; + DEVELOPMENT_TEAM = LM2TBH9R6C; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.miracle.tinyUITests; @@ -611,7 +702,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6PN64DQKLX; + DEVELOPMENT_TEAM = LM2TBH9R6C; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.miracle.tinyUITests; @@ -668,6 +759,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 12.6.0; + }; + }; 430725D42EC5C69D006234D7 /* XCRemoteSwiftPackageReference "Orb" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/metasidd/Orb"; @@ -695,6 +794,126 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 274B0DBB2ED9B59500825816 /* FirebaseAI */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAI; + }; + 274B0DBD2ED9B59500825816 /* FirebaseAILogic */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAILogic; + }; + 274B0DBF2ED9B59500825816 /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + 274B0DC12ED9B59500825816 /* FirebaseAnalyticsCore */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalyticsCore; + }; + 274B0DC32ED9B59500825816 /* FirebaseAnalyticsIdentitySupport */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalyticsIdentitySupport; + }; + 277D766A2ED9E777008C8404 /* FirebaseAppCheck */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAppCheck; + }; + 277D766C2ED9E777008C8404 /* FirebaseAppDistribution-Beta */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = "FirebaseAppDistribution-Beta"; + }; + 277D766E2ED9E777008C8404 /* FirebaseAuth */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAuth; + }; + 277D76702ED9E777008C8404 /* FirebaseAuthCombine-Community */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = "FirebaseAuthCombine-Community"; + }; + 277D76722ED9E777008C8404 /* FirebaseCore */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCore; + }; + 277D76742ED9E777008C8404 /* FirebaseCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCrashlytics; + }; + 277D76762ED9E777008C8404 /* FirebaseDatabase */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseDatabase; + }; + 277D76782ED9E777008C8404 /* FirebaseFirestore */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseFirestore; + }; + 277D767A2ED9E777008C8404 /* FirebaseFirestoreCombine-Community */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = "FirebaseFirestoreCombine-Community"; + }; + 277D767C2ED9E777008C8404 /* FirebaseFunctions */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseFunctions; + }; + 277D767E2ED9E777008C8404 /* FirebaseFunctionsCombine-Community */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = "FirebaseFunctionsCombine-Community"; + }; + 277D76802ED9E777008C8404 /* FirebaseInAppMessaging-Beta */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = "FirebaseInAppMessaging-Beta"; + }; + 277D76822ED9E777008C8404 /* FirebaseInstallations */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseInstallations; + }; + 277D76842ED9E777008C8404 /* FirebaseMessaging */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMessaging; + }; + 277D76862ED9E777008C8404 /* FirebaseMLModelDownloader */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMLModelDownloader; + }; + 277D76882ED9E777008C8404 /* FirebasePerformance */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebasePerformance; + }; + 277D768A2ED9E777008C8404 /* FirebaseRemoteConfig */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseRemoteConfig; + }; + 277D768C2ED9E777008C8404 /* FirebaseStorage */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseStorage; + }; + 277D768E2ED9E777008C8404 /* FirebaseStorageCombine-Community */ = { + isa = XCSwiftPackageProductDependency; + package = 274B0DBA2ED9B59500825816 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = "FirebaseStorageCombine-Community"; + }; 430725D52EC5C791006234D7 /* Orb */ = { isa = XCSwiftPackageProductDependency; package = 430725D42EC5C69D006234D7 /* XCRemoteSwiftPackageReference "Orb" */; diff --git a/Tiny.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tiny.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0f390f5..5b05ed8 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" : "f773aab8c0c4520db807113914aa2895c4f7913351d6bb67dc7a3c1c06b1c3ad", "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..273c153 100644 --- a/Tiny/App/tinyApp.swift +++ b/Tiny/App/tinyApp.swift @@ -7,16 +7,26 @@ import SwiftUI import SwiftData +import FirebaseCore @main struct TinyApp: App { @StateObject var heartbeatSoundManager = HeartbeatSoundManager() + @StateObject private var themeManager = ThemeManager() + @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([ - SavedHeartbeat.self + SavedHeartbeat.self, + SavedMoment.self ]) let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) @@ -31,10 +41,15 @@ struct TinyApp: App { WindowGroup { if isShowingSplashScreen { SplashScreenView(isShowingSplashScreen: $isShowingSplashScreen) + .environmentObject(themeManager) .preferredColorScheme(.dark) } else { ContentView() .environmentObject(heartbeatSoundManager) + .environmentObject(authService) + .environmentObject(syncManager) + .environmentObject(themeManager) + .preferredColorScheme(.dark) } } .modelContainer(sharedModelContainer) diff --git a/Tiny/Core/Animation/TimelineAnimationController.swift b/Tiny/Core/Animation/TimelineAnimationController.swift new file mode 100644 index 0000000..e0370f3 --- /dev/null +++ b/Tiny/Core/Animation/TimelineAnimationController.swift @@ -0,0 +1,97 @@ +// +// TimelineAnimationController.swift +// Tiny +// +// Animation state management for timeline first-time experience +// + +import SwiftUI +internal import Combine + +enum AnimationPhase { + case empty + case drawingPath + case showingDots + case transformingOrb + case showingProfile + case complete +} + +class TimelineAnimationController: ObservableObject { + @Published var currentPhase: AnimationPhase = .empty + @Published var pathProgress: CGFloat = 0.0 + @Published var dotsVisible: [Bool] = [false, false, false] + @Published var orbVisible: Bool = false + @Published var profileVisible: Bool = false + + // Timing constants (in seconds) - Slowed down for better visibility + private let pathDuration: Double = 2.5 // Was 1.5 + private let dotDelay: Double = 0.9 // Was 0.33 + private let dotDuration: Double = 0.9 // Was 0.3 + private let transformDuration: Double = 1.0 // Was 0.7 + private let profileDuration: Double = 0.8 // Was 0.6 + + func startAnimation() { + currentPhase = .drawingPath + animatePathDrawing() + } + + private func animatePathDrawing() { + withAnimation(.easeInOut(duration: pathDuration)) { + pathProgress = 1.0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + pathDuration) { + self.currentPhase = .showingDots + self.animateDotsAppearing() + } + } + + private func animateDotsAppearing() { + // Show dots sequentially + for index in 0..<3 { + DispatchQueue.main.asyncAfter(deadline: .now() + (Double(index) * dotDelay)) { + withAnimation(.easeOut(duration: self.dotDuration)) { + self.dotsVisible[index] = true + } + } + } + + // After all dots appear, transform first dot to orb + let totalDotTime = Double(3) * dotDelay + dotDuration + DispatchQueue.main.asyncAfter(deadline: .now() + totalDotTime) { + self.currentPhase = .transformingOrb + self.animateOrbTransform() + } + } + + private func animateOrbTransform() { + withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { + dotsVisible[0] = false + orbVisible = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + transformDuration) { + self.currentPhase = .showingProfile + self.animateProfileAppear() + } + } + + private func animateProfileAppear() { + withAnimation(.easeIn(duration: profileDuration)) { + profileVisible = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + profileDuration) { + self.currentPhase = .complete + } + } + + func skipAnimation() { + currentPhase = .complete + pathProgress = 1.0 + dotsVisible = [false, false, false] + orbVisible = true + profileVisible = true + } +} diff --git a/Tiny/Core/Components/BokehEffectView.swift b/Tiny/Core/Components/BokehEffectView.swift index 2142cfe..241457b 100644 --- a/Tiny/Core/Components/BokehEffectView.swift +++ b/Tiny/Core/Components/BokehEffectView.swift @@ -9,6 +9,7 @@ import SwiftUI internal import Combine struct BokehEffectView: View { + @EnvironmentObject var themeManager: ThemeManager @Binding var amplitude: Float private var pulseOpacity: Double { @@ -19,13 +20,17 @@ struct BokehEffectView: View { private var pulseScale: CGFloat { return 1.0 + CGFloat(amplitude) * 0.3 } + + private var bokehColor: Color { + themeManager.selectedOrbStyle.bokehColor + } var body: some View { ZStack { // Layer 1: The Base Glow (Shifted Left) // This is the bottom layer, slightly less bright. Circle() - .fill(Color.orbLightYellow) + .fill(bokehColor) .frame(width: 30) .opacity(pulseOpacity * 0.5) .scaleEffect(pulseScale * 1) @@ -34,7 +39,7 @@ struct BokehEffectView: View { // Layer 2: The Core/Highlight (Shifted Right) // *** We apply .blendMode(.screen) here to brighten the overlap *** Circle() - .fill(Color.orbLightYellow) + .fill(bokehColor) .frame(width: 30) .opacity(pulseOpacity * 0.5) .scaleEffect(pulseScale * 1.1) diff --git a/Tiny/Core/Components/Orb/Models/OrbStyles.swift b/Tiny/Core/Components/Orb/Models/OrbStyles.swift index 043548a..dde91d7 100644 --- a/Tiny/Core/Components/Orb/Models/OrbStyles.swift +++ b/Tiny/Core/Components/Orb/Models/OrbStyles.swift @@ -8,43 +8,72 @@ import SwiftUI enum OrbStyles: String, CaseIterable, Identifiable { - case ocean = "Ocean" - case defaultStyle = "Default" - case forest = "Forest" + case yellow = "Yellow" + case pink = "Pink" + case purple = "Purple" + case blue = "Blue" + case green = "Green" var id: String { rawValue } var displayName: String { rawValue } var backgorundColors: [Color] { switch self { - case .ocean: - return [.blue, .cyan, .clear] - case .forest: - return [.green, .mint, .clear] - case .defaultStyle: - return [.orange, .orbOrange, .clear] + case .yellow: + return [Color("orbYellow"), Color("orbYellow"), .clear] + case .pink: + return [Color("orbPink"), Color("orbPink"), .clear] + case .purple: + return [Color("orbPurple"), Color("orbPurple"), .clear] + case .blue: + return [Color("orbBlue"), Color("orbBlue"), .clear] + case .green: + return [Color("orbGreen"), Color("orbGreen"), .clear] } } var glowColor: Color { switch self { - case .ocean: - return .cyan.opacity(1) - case .forest: - return .green.opacity(1) - case .defaultStyle: - return .orbOrange.opacity(1) + case .yellow: + return Color("orbYellow") + case .pink: + return Color("orbPink") + case .purple: + return Color("orbPurple") + case .blue: + return Color("orbBlue") + case .green: + return Color("orbGreen") } } var particleColor: Color { switch self { - case .ocean: - return .cyan - case .forest: - return .green - case .defaultStyle: - return .white + case .yellow: + return Color("orbYellow") + case .pink: + return Color("orbPink") + case .purple: + return Color("orbPurple") + case .blue: + return Color("orbBlue") + case .green: + return Color("orbGreen") + } + } + + var bokehColor: Color { + switch self { + case .yellow: + return Color("bokehYellow") + case .pink: + return Color("bokehPink") + case .purple: + return Color("bokehPurple") + case .blue: + return Color("bokehBlue") + case .green: + return Color("bokehGreen") } } diff --git a/Tiny/Core/Components/Orb/Views/AnimatedOrbView.swift b/Tiny/Core/Components/Orb/Views/AnimatedOrbView.swift index 6883520..10de624 100644 --- a/Tiny/Core/Components/Orb/Views/AnimatedOrbView.swift +++ b/Tiny/Core/Components/Orb/Views/AnimatedOrbView.swift @@ -13,12 +13,14 @@ import SwiftUI import SpriteKit struct AnimatedOrbView: View { + @EnvironmentObject var themeManager: ThemeManager @StateObject private var physicsController = OrbPhysicsController() var size: CGFloat = 200 - var style: OrbStyles = .defaultStyle + var style: OrbStyles? + private var effectiveStyle: OrbStyles { style ?? themeManager.selectedOrbStyle } private var configuration: OrbConfiguration { - OrbConfiguration(style: style) + OrbConfiguration(style: effectiveStyle) } var body: some View { @@ -29,9 +31,9 @@ struct AnimatedOrbView: View { .stroke( AngularGradient( colors: [ - style.glowColor.opacity(0.8), - style.glowColor.opacity(2), - style.glowColor.opacity(0.6) + effectiveStyle.glowColor.opacity(0.8), + effectiveStyle.glowColor.opacity(2), + effectiveStyle.glowColor.opacity(0.6) ], center: .center, startAngle: .degrees(0), diff --git a/Tiny/Core/Components/Orb/Views/OrbStylePicker.swift b/Tiny/Core/Components/Orb/Views/OrbStylePicker.swift index 7e8bfce..01d245a 100644 --- a/Tiny/Core/Components/Orb/Views/OrbStylePicker.swift +++ b/Tiny/Core/Components/Orb/Views/OrbStylePicker.swift @@ -56,7 +56,7 @@ struct OrbStylePicker: View { } #Preview { - @Previewable @State var selectedStyle: OrbStyles = .ocean + @Previewable @State var selectedStyle: OrbStyles = .yellow VStack { OrbStylePicker(selectedStyle: $selectedStyle) 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/Components/SplashScreenView.swift b/Tiny/Core/Components/SplashScreenView.swift index 781c59b..8033d5b 100644 --- a/Tiny/Core/Components/SplashScreenView.swift +++ b/Tiny/Core/Components/SplashScreenView.swift @@ -8,19 +8,23 @@ import SwiftUI struct SplashScreenView: View { + @EnvironmentObject var themeManager: ThemeManager @Binding var isShowingSplashScreen: Bool @State private var animate: Bool = false var body: some View { ZStack { - Image("bgSplashScreen") + // Use customizable background from ThemeManager + Color.black.ignoresSafeArea() + Image(themeManager.selectedBackground.imageName) .resizable() .scaledToFill() + .ignoresSafeArea() Image("titleSplashScreen") .resizable() .scaledToFill() - .frame(width: animate ? 100 :80, height: animate ? 100 : 80) // Animate size + .frame(width: animate ? 100 : 80, height: animate ? 100 : 80) // Animate size .opacity(animate ? 1 : 0.5) // Animate opacity } .ignoresSafeArea() @@ -39,5 +43,6 @@ struct SplashScreenView: View { #Preview { SplashScreenView(isShowingSplashScreen: .constant(true)) + .environmentObject(ThemeManager()) .preferredColorScheme(.dark) } diff --git a/Tiny/Core/Components/Theme/Models/BackgroundTheme.swift b/Tiny/Core/Components/Theme/Models/BackgroundTheme.swift new file mode 100644 index 0000000..14be2e9 --- /dev/null +++ b/Tiny/Core/Components/Theme/Models/BackgroundTheme.swift @@ -0,0 +1,45 @@ +// +// BackgroundTheme.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 28/11/25. +// + +import Foundation +import SwiftUI + +enum BackgroundTheme: String, CaseIterable, Identifiable { + case purple = "Purple" + case pink = "Pink" + case blue = "Blue" + case black = "Black" + + var id: String { rawValue } + var displayName: String { rawValue } + + var imageName: String { + switch self { + case .purple: + return "bgPurple" + case .pink: + return "bgPink" + case .blue: + return "bgBlue" + case .black: + return "bgBlack" + } + } + + var color: Color { + switch self { + case .purple: + return Color(hex: "32173F") ?? Color.black + case .pink: + return Color(hex: "3F1738") ?? Color.black + case .blue: + return Color(hex: "19173F") ?? Color.black + case .black: + return Color.black + } + } +} diff --git a/Tiny/Core/Components/Tutorial/TutorialView.swift b/Tiny/Core/Components/Tutorial/TutorialView.swift new file mode 100644 index 0000000..d38de65 --- /dev/null +++ b/Tiny/Core/Components/Tutorial/TutorialView.swift @@ -0,0 +1,342 @@ +// +// TutorialView.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 01/12/25. +// + +import SwiftUI + +// --------------------------------------------------------- +// MARK: - Highlighted Word Component +// --------------------------------------------------------- +struct HighlightedWordText: View { + let fullText: String + let highlight: String + let highlightColor: Color + + var body: some View { + Text(makeAttributed()) + } + + private func makeAttributed() -> AttributedString { + var attributed = AttributedString(fullText) + + if let range = attributed.range(of: highlight) { + attributed[range].foregroundColor = highlightColor + } + return attributed + } +} + +// --------------------------------------------------------- +// MARK: MAIN TUTORIAL VIEW +// --------------------------------------------------------- +struct TutorialView: View { + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + TutorialPage1() + TutorialPage2() + TutorialPage3() + TutorialPage4() + } + .padding(.bottom, 40) + } + .background( + Image("bgPurpleOnboarding") + .resizable() + .scaledToFill() + .ignoresSafeArea() + ) + .navigationTitle("Tutorial") + .navigationBarTitleDisplayMode(.inline) + } +} + +// --------------------------------------------------------- +// MARK: TUTORIAL PAGE 1 +// --------------------------------------------------------- +struct TutorialPage1: View { + @State private var phoneOffset: CGFloat = -40 + @State private var phoneRotation: Double = -5 + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + + Text("Tiny") + .font(.title) + .fontWeight(.bold) + .padding(.horizontal, 30) + + Text("your gentle guide") + .font(.subheadline) + .fontWeight(.medium) + .padding(.horizontal, 30) + .padding(.bottom, 20) + + // Title + HighlightedWordText( + fullText: "What can you do with Tiny", + highlight: "Tiny", + highlightColor: Color("mainYellow") + ) + .font(.title2) + .fontWeight(.bold) + .padding(.horizontal, 30) + + Text("Connect your AirPods and let Tiny access your microphone to hear every little beat.") + .font(.body) + .padding(.horizontal, 30) + + HStack { + Spacer() + ZStack { + VStack { + Image("handHoldingPhone") + .offset(x: phoneOffset) + .rotationEffect(.degrees(phoneRotation)) + .onAppear { + withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) { + phoneOffset = 40 + phoneRotation = 5 + } + } + + Image("stomach") + } + } + Spacer() + } + .padding(.top, 20) + } + .padding(.vertical, 80) + } +} + +// --------------------------------------------------------- +// MARK: TUTORIAL PAGE 2 +// --------------------------------------------------------- +struct TutorialPage2: View { + var body: some View { + VStack(alignment: .leading, spacing: 16) { + + HighlightedWordText( + fullText: "Feel the best experience", + highlight: "best", + highlightColor: Color("mainYellow") + ) + .font(.title2) + .fontWeight(.bold) + .padding(.horizontal, 30) + + Text("Tiny will need access to your microphone so you can hear every tiny beat clearly.") + .font(.body) + .padding(.horizontal, 30) + + HStack { + Spacer() + + HStack { + Image(systemName: "airpod.gen3.right") + .font(.system(size: 80)) + .rotationEffect(.degrees(-10)) + + Image(systemName: "airpod.gen3.left") + .font(.system(size: 80)) + .rotationEffect(.degrees(10)) + .offset(y: 10) + } + .mask( + LinearGradient( + gradient: Gradient(colors: [.white, .white.opacity(0.3)]), + startPoint: .top, + endPoint: .bottom + ) + ) + + Spacer() + } + .padding(.top, 20) + } + .padding(.vertical, 80) + } +} + +// --------------------------------------------------------- +// MARK: TUTORIAL PAGE 3 +// --------------------------------------------------------- +struct TutorialPage3: View { + var body: some View { + VStack(alignment: .leading, spacing: 16) { + + HighlightedWordText( + fullText: "Grow through every moment", + highlight: "moment", + highlightColor: Color("mainYellow") + ) + .font(.title2) + .fontWeight(.bold) + .padding(.horizontal, 30) + + Text("Share how you feel today and let love keep you both close.") + .font(.body) + .padding(.horizontal, 30) + + HStack { + Spacer() + + Image("onboardingShareMood") + .resizable() + .scaledToFit() + .frame(height: 180) + .padding(.top, 20) + + Spacer() + } + } + .padding(.vertical, 80) + } +} + +// --------------------------------------------------------- +// MARK: TUTORIAL PAGE 4 +// --------------------------------------------------------- +struct TutorialPage4: View { + + var body: some View { + VStack(alignment: .leading, spacing: 32) { + + // SECTION 1 - full white title + HighlightedWordText( + fullText: "To hear every little beat clearly:", + highlight: "", + highlightColor: Color("mainYellow") + ) + .font(.title3) + .fontWeight(.bold) + + section( + title: "", + bullets: [ + "Allow Tiny to access your microphone", + "Connect your AirPods / TWS", + "Remove your phone case", + "Make sure nothing blocks your iPhone’s mic", + "Place the phone directly on skin", + "Find a quiet room", + "Tiny works offline — no internet needed" + ] + ) + + // SECTION 2 - only "companion" yellow + VStack(alignment: .leading, spacing: 12) { + + HighlightedWordText( + fullText: "Sweet companion for bonding", + highlight: "companion", + highlightColor: Color("mainYellow") + ) + .font(.title3) + .fontWeight(.bold) + + Text("Every pregnancy is beautifully different, so results may vary. Be gentle with yourself if it doesn’t work right away.") + .font(.body) + + section( + title: "", + bullets: [ + "Baby’s position may not align with the mic", + "Skin thickness varies with every pregnancy", + "Sometimes it’s the device or technology", + "Early weeks: heartbeat may still be too faint" + ] + ) + } + + // SECTION 3 - full white title + section( + title: "To hear every little beat clearly:", + bullets: [ + "Baby’s position is unpredictable", + "Skin thickness varies", + "Sometimes it’s simply the device & technology", + "Early weeks: heartbeat may still be too faint" + ] + ) + + // SECTION 4 - only "Navigate" yellow + VStack(alignment: .leading, spacing: 16) { + + HighlightedWordText( + fullText: "Navigate between features", + highlight: "Navigate", + highlightColor: Color("mainYellow") + ) + .font(.title3) + .fontWeight(.bold) + + Text("Control your screen with a simple gesture:") + .font(.body) + + section( + title: "LIVE LISTEN", + bullets: [ + "Start session → Double-tap the sphere", + "Stop session → Press & hold the sphere" + ] + ) + + section( + title: "PLAYBACK", + bullets: [ + "Play recording → Tap the sphere", + "Pause / stop → Tap again" + ] + ) + + section( + title: "SAVE RECORDING", + bullets: [ + "Hold + drag down → Delete recording", + "Hold + swipe up → Save recording" + ] + ) + } + } + .padding(.horizontal, 30) + .padding(.vertical, 40) + } + + // ----------------------------------------------------- + // MARK: UNIVERSAL BULLET SECTION + // ----------------------------------------------------- + func section(title: String, bullets: [String]) -> some View { + VStack(alignment: .leading, spacing: 12) { + + if !title.isEmpty { + Text(title) + .font(.headline) + } + + VStack(alignment: .leading, spacing: 8) { + ForEach(bullets, id: \.self) { bullet in + HStack(alignment: .top, spacing: 8) { + Image(systemName: "circle.fill") + .font(.system(size: 6)) + .padding(.top, 6) + + Text(bullet) + .font(.body) + .multilineTextAlignment(.leading) + } + } + } + } + } +} + +#Preview { + TutorialView() + .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..a7d1135 100644 --- a/Tiny/Core/Models/SavedHeartbeatModel.swift +++ b/Tiny/Core/Models/SavedHeartbeatModel.swift @@ -7,16 +7,92 @@ 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()) { + var displayName: String? // Custom name for the recording + + // 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(), + displayName: String? = nil, + 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.displayName = displayName + 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 displayName = displayName { + dict["displayName"] = displayName + } + 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, + displayName: data["displayName"] as? String, + 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/SavedMomentModel.swift b/Tiny/Core/Models/SavedMomentModel.swift new file mode 100644 index 0000000..41240fd --- /dev/null +++ b/Tiny/Core/Models/SavedMomentModel.swift @@ -0,0 +1,46 @@ +// +// SavedMomentModel.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import SwiftData +import Foundation + +@Model +class SavedMoment { + @Attribute(.unique) var id: UUID + var filePath: String // Local file path for the image + var timestamp: Date + var pregnancyWeeks: Int? + + // Firebase fields + var firebaseId: String? + var motherUserId: String? + var roomCode: String? + var isShared: Bool + var firebaseStorageURL: String? + var isSyncedToCloud: Bool + + init(filePath: String, + timestamp: Date = Date(), + pregnancyWeeks: Int? = nil, + firebaseId: String? = nil, + motherUserId: String? = nil, + roomCode: String? = nil, + isShared: Bool = true, + firebaseStorageURL: String? = nil, + isSyncedToCloud: Bool = false) { + self.id = UUID() + self.filePath = filePath + self.timestamp = timestamp + self.pregnancyWeeks = pregnancyWeeks + self.firebaseId = firebaseId + self.motherUserId = motherUserId + self.roomCode = roomCode + self.isShared = isShared + self.firebaseStorageURL = firebaseStorageURL + self.isSyncedToCloud = isSyncedToCloud + } +} diff --git a/Tiny/Core/Models/User.swift b/Tiny/Core/Models/User.swift new file mode 100644 index 0000000..7212ffc --- /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 pregnancyWeeks: Int? + var roomCode: String? + var createdAt: Date + + init(id: String? = nil, + email: String, + name: String? = nil, + role: UserRole? = nil, + pregnancyWeeks: Int? = nil, + roomCode: String? = nil, + createdAt: Date) { + self.id = id + self.email = email + self.name = name + self.role = role + self.pregnancyWeeks = pregnancyWeeks + self.roomCode = roomCode + self.createdAt = createdAt + } +} diff --git a/Tiny/Core/Services/Audio/AudioPostProcessingManager.swift b/Tiny/Core/Services/Audio/AudioPostProcessingManager.swift index bfa5aa7..6f5111e 100644 --- a/Tiny/Core/Services/Audio/AudioPostProcessingManager.swift +++ b/Tiny/Core/Services/Audio/AudioPostProcessingManager.swift @@ -25,6 +25,15 @@ class AudioPostProcessingManager: ObservableObject { @Published var currentTime: TimeInterval = 0 @Published var duration: TimeInterval = 0 @Published var amplitude: Float = 0.0 + + var isHapticsEnabled: Bool { + hapticManager?.isHapticsEnabled ?? false + } + + func toggleHaptics() { + hapticManager?.isHapticsEnabled.toggle() + objectWillChange.send() + } init() { engine = AudioEngine() diff --git a/Tiny/Core/Services/Audio/HapticManager.swift b/Tiny/Core/Services/Audio/HapticManager.swift index 897f8a2..33056e3 100644 --- a/Tiny/Core/Services/Audio/HapticManager.swift +++ b/Tiny/Core/Services/Audio/HapticManager.swift @@ -14,7 +14,16 @@ class HapticManager { private let amplitudeThresholdLower: Float = 0.08 // Triggers for sounds above this private let amplitudeThresholdUpper: Float = 0.2 // Does not trigger for sounds above this (too loud noise) + var isHapticsEnabled: Bool { + get { UserDefaults.standard.bool(forKey: "isHapticsEnabled") } + set { UserDefaults.standard.set(newValue, forKey: "isHapticsEnabled") } + } + init() { + // Initialize default value if not set + if UserDefaults.standard.object(forKey: "isHapticsEnabled") == nil { + UserDefaults.standard.set(true, forKey: "isHapticsEnabled") + } prepareHaptics() } @@ -55,6 +64,8 @@ class HapticManager { } func playHapticFromAmplitude(_ amplitude: Float) { + guard isHapticsEnabled else { return } + let now = Date() var shouldTriggerHaptic = false diff --git a/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift index 79d23d2..d71949f 100644 --- a/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift +++ b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift @@ -22,9 +22,22 @@ struct Recording: Identifiable, Equatable { let id = UUID() let fileURL: URL let createdAt: Date + var displayName: String? // Custom name from SwiftData var isPlaying: Bool = false } +struct Moment: Identifiable, Equatable { + let id: UUID + let fileURL: URL + let createdAt: Date + + init(id: UUID = UUID(), fileURL: URL, createdAt: Date) { + self.id = id + self.fileURL = fileURL + self.createdAt = createdAt + } +} + struct HeartbeatData { let timestamp: Date let bpm: Double @@ -43,6 +56,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 +69,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,12 +80,13 @@ 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 @Published var lastRecording: Recording? @Published var savedRecordings: [Recording] = [] + @Published var savedMoments: [Moment] = [] @Published var isPlayingPlayback = false @Published var amplitudeVal: Float = 0.0 @Published var blinkAmplitude: Float = 0.0 @@ -83,14 +103,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 { @@ -103,22 +123,126 @@ class HeartbeatSoundManager: NSObject, ObservableObject { guard let modelContext = modelContext else { return } do { let results = try modelContext.fetch(FetchDescriptor()) - let documentsURL = getDocumentsDirectory() + _ = getDocumentsDirectory() DispatchQueue.main.async { - // ✅ Map the REAL timestamp from SwiftData - 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)") + if let displayName = savedItem.displayName { + print(" Custom name: \(displayName)") + } return Recording( - fileURL: currentURL, + fileURL: fileURL, + createdAt: savedItem.timestamp, + displayName: savedItem.displayName + ) + } + print("✅ Loaded \(self.savedRecordings.count) recordings from SwiftData") + } + + // Load Moments + let momentResults = try modelContext.fetch(FetchDescriptor()) + DispatchQueue.main.async { + self.savedMoments = momentResults.compactMap { savedItem in + // Fix: Use filename only to reconstruct path, as absolute paths change on container recreation + let storedPath = savedItem.filePath + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let fileURL = self.getDocumentsDirectory().appendingPathComponent(fileName) + + if !FileManager.default.fileExists(atPath: fileURL.path) { + print("⚠️ Moment file missing: \(fileURL.path)") + return nil + } + + return Moment( + id: savedItem.id, + fileURL: fileURL, createdAt: savedItem.timestamp ) } + print("✅ Loaded \(self.savedMoments.count) moments from SwiftData") + } + + // Sync from cloud if we have the necessary info + if let roomCode = currentRoomCode, let syncManager = syncManager { + Task { @MainActor in + do { + print("🔄 Starting cloud sync...") + let isMother = currentUserRole == .mother + + // Fetch heartbeats from cloud + _ = try await syncManager.syncHeartbeatsFromCloud( + roomCode: roomCode, + modelContext: modelContext, + isMother: isMother + ) + + // Fetch moments from cloud + _ = try await syncManager.syncMomentsFromCloud( + roomCode: roomCode, + modelContext: modelContext + ) + + // Reload from SwiftData after sync + let updatedResults = try modelContext.fetch(FetchDescriptor()) + let updatedMoments = try modelContext.fetch(FetchDescriptor()) + + // Show all heartbeats for both mothers and fathers (all are shared by default) + self.savedRecordings = updatedResults.compactMap { savedItem in + 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, + displayName: savedItem.displayName + ) + } + print("✅ Reloaded \(self.savedRecordings.count) recordings after sync (isMother: \(isMother))") + + // Reload moments with SavedMoment IDs to prevent duplicates + self.savedMoments = updatedMoments.compactMap { savedItem in + // Fix: Use filename only + let storedPath = savedItem.filePath + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let fileURL = self.getDocumentsDirectory().appendingPathComponent(fileName) + + if !FileManager.default.fileExists(atPath: fileURL.path) { + return nil + } + + return Moment( + id: savedItem.id, + fileURL: fileURL, + createdAt: savedItem.timestamp + ) + } + print("✅ Reloaded \(self.savedMoments.count) moments after sync") + + // Force UI update + self.objectWillChange.send() + } catch { + print("❌ Cloud sync failed: \(error.localizedDescription)") + } + } } - print("✅ Loaded \(self.savedRecordings.count) recordings from SwiftData") } catch { print("SwiftData load error: \(error)") } @@ -127,92 +251,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 +346,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 +372,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 +398,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 +415,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { filterChainBuilder.updateTraditionalFilterChain(lowCutoff, highCutoff, aggressiveFiltering: aggressiveFiltering) } } - + func getFilterFrequencies(for mode: HeartbeatFilterMode) -> (Float, Float) { switch mode { case .standard: @@ -304,7 +428,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { return (15.0, 2500.0) // Optimized for spatial processing } } - + func setFilterMode(_ mode: HeartbeatFilterMode) { filterMode = mode if isRunning { @@ -312,7 +436,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 +454,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 +494,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 +504,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 +591,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 +614,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } } } - + func togglePlayback(recording: Recording) { if player?.isPlaying == true { player?.stop() @@ -503,12 +627,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 +673,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } } } - + engine.output = player try engine.start() player?.play() @@ -526,12 +681,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 +699,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 +715,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 +732,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { print("Error deactivating audio session: \(error.localizedDescription)") } } - + isRunning = false } @@ -584,17 +741,277 @@ class HeartbeatSoundManager: NSObject, ObservableObject { self.savedRecordings.append(recording) guard let modelContext = modelContext else { return } - + + // Calculate current pregnancy week + let pregnancyWeek: Int? = { + guard let pregnancyStartDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + return nil + } + let calendar = Calendar.current + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: recording.createdAt).weekOfYear ?? 0 + return weeksSinceStart + }() + + // Create entry with isShared = true by default and pregnancy week let entry = SavedHeartbeat( filePath: recording.fileURL.path, - timestamp: recording.createdAt + timestamp: recording.createdAt, + isShared: true, // Auto-share all heartbeats + pregnancyWeeks: pregnancyWeek ) 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)") + } + } + + func deleteRecording(_ recording: Recording) { + guard let modelContext = modelContext else { return } + + print("🗑️ Deleting recording: \(recording.fileURL.lastPathComponent)") + + // Remove from savedRecordings array + if let index = savedRecordings.firstIndex(where: { $0.id == recording.id }) { + savedRecordings.remove(at: index) + print("✅ Removed from savedRecordings array. New count: \(savedRecordings.count)") + // Force UI update + objectWillChange.send() + } + + // Remove from SwiftData + do { + let filePath = recording.fileURL.path + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.filePath == filePath } + ) + let results = try modelContext.fetch(descriptor) + + for entry in results { + modelContext.delete(entry) + } + + try modelContext.save() + print("✅ Deleted from SwiftData") + + // Delete the audio file from disk + let fileManager = FileManager.default + if fileManager.fileExists(atPath: filePath) { + try fileManager.removeItem(atPath: filePath) + print("✅ Deleted audio file from disk") + } + + // Clear lastRecording if it's the one being deleted + if lastRecording?.id == recording.id { + lastRecording = nil + } + + // Delete from Firebase if it was synced + if let firebaseId = results.first?.firebaseId, + let syncManager = syncManager, + let entry = results.first { + Task { @MainActor in + do { + try await syncManager.deleteHeartbeat(entry) + print("✅ Deleted from Firebase") + } catch { + print("❌ Firebase deletion failed: \(error.localizedDescription)") + } + } + } + + } catch { + print("❌ Delete failed: \(error)") + } + } + + // MARK: - Moment Management + + func saveMoment(image: UIImage) { + guard let modelContext = modelContext else { return } + + // Save image to disk + guard let data = image.jpegData(compressionQuality: 0.8) else { return } + let timestamp = Date() + let filename = "moment-\(timestamp.timeIntervalSince1970).jpg" + let fileURL = getDocumentsDirectory().appendingPathComponent(filename) + + do { + try data.write(to: fileURL) + + // Calculate pregnancy week + let pregnancyWeek: Int? = { + guard let pregnancyStartDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + return nil + } + let calendar = Calendar.current + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: Date()).weekOfYear ?? 0 + return weeksSinceStart + }() + + // Save to SwiftData + // Store just the filename (relative path) to avoid absolute path issues + let savedMoment = SavedMoment( + filePath: filename, + timestamp: timestamp, + pregnancyWeeks: pregnancyWeek + ) + + modelContext.insert(savedMoment) + try modelContext.save() + + print("✅ Saved moment to SwiftData") + print(" File: \(filename)") + print(" Timestamp: \(timestamp)") + print(" Pregnancy Week: \(pregnancyWeek ?? -1)") + + // Update local array + let moment = Moment(id: savedMoment.id, fileURL: fileURL, createdAt: savedMoment.timestamp) + DispatchQueue.main.async { + self.savedMoments.append(moment) + self.objectWillChange.send() + } + + print("✅ Added moment to local array (count: \(savedMoments.count + 1))") + + // Upload to Firebase if possible + print("🔍 Checking Firebase upload requirements:") + print(" currentUserId: \(currentUserId ?? "nil")") + print(" currentRoomCode: \(currentRoomCode ?? "nil")") + print(" syncManager: \(syncManager != nil ? "available" : "nil")") + + if let userId = currentUserId, let roomCode = currentRoomCode, let syncManager = syncManager { + Task { @MainActor in + do { + print("📤 Uploading moment to Firebase...") + try await syncManager.uploadMoment(savedMoment, motherUserId: userId, roomCode: roomCode) + print("✅ Uploaded moment to Firebase") + print(" Storage URL: \(savedMoment.firebaseStorageURL ?? "not set")") + print(" Firebase ID: \(savedMoment.firebaseId ?? "not set")") + } catch { + print("❌ Failed to upload moment: \(error)") + } + } + } else { + print("⚠️ Skipping Firebase upload - missing required data") + if currentUserId == nil { print(" - User ID is nil") } + if currentRoomCode == nil { print(" - Room Code is nil") } + if syncManager == nil { print(" - Sync Manager is nil") } + } + + } catch { + print("❌ Failed to save moment: \(error)") + } + } + + func deleteMoment(_ moment: Moment) { + guard let modelContext = modelContext else { return } + + print("🗑️ Deleting moment with ID: \(moment.id)") + + // Remove from savedMoments array + if let index = savedMoments.firstIndex(where: { $0.id == moment.id }) { + savedMoments.remove(at: index) + print("✅ Removed from savedMoments array. New count: \(savedMoments.count)") + objectWillChange.send() + } + + // Remove from SwiftData using the Moment's ID (which is the SavedMoment's ID) + do { + // Capture the ID first for use in the Predicate + let momentId = moment.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == momentId } + ) + let results = try modelContext.fetch(descriptor) + + guard let entry = results.first else { + print("⚠️ No SavedMoment found with ID: \(moment.id)") + return + } + + let filePath = entry.filePath + let fileName = URL(fileURLWithPath: filePath).lastPathComponent + let fullPath = getDocumentsDirectory().appendingPathComponent(fileName).path + + // Delete from Firebase FIRST (before deleting from SwiftData) + if let firebaseId = entry.firebaseId, let syncManager = syncManager { + Task { @MainActor in + do { + print("🔥 Deleting moment from Firebase...") + try await syncManager.deleteMoment(entry) + print("✅ Deleted moment from Firebase") + print(" Firebase ID: \(firebaseId)") + + // After Firebase deletion succeeds, delete locally + self.deleteLocalMoment(entry: entry, fullPath: fullPath, modelContext: modelContext) + } catch { + print("❌ Failed to delete moment from Firebase: \(error)") + // Still delete locally even if Firebase delete fails + self.deleteLocalMoment(entry: entry, fullPath: fullPath, modelContext: modelContext) + } + } + } else { + // No Firebase sync, just delete locally + print("ℹ️ No Firebase ID, deleting locally only") + deleteLocalMoment(entry: entry, fullPath: fullPath, modelContext: modelContext) + } + + } catch { + print("❌ Delete moment failed: \(error)") + } + } + + private func deleteLocalMoment(entry: SavedMoment, fullPath: String, modelContext: ModelContext) { + do { + // Delete from SwiftData + modelContext.delete(entry) + try modelContext.save() + print("✅ Deleted moment from SwiftData") + + // Delete file from disk + let fileManager = FileManager.default + if fileManager.fileExists(atPath: fullPath) { + try fileManager.removeItem(atPath: fullPath) + print("✅ Deleted moment file from disk: \(fullPath)") + } else { + print("ℹ️ File not found on disk: \(fullPath)") + } } catch { - print("SwiftData save failed: \(error)") + print("❌ Failed to delete local moment: \(error)") } } } diff --git a/Tiny/Core/Services/Authentication/AuthenticationService.swift b/Tiny/Core/Services/Authentication/AuthenticationService.swift new file mode 100644 index 0000000..1745ba8 --- /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, + pregnancyWeeks: 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, pregnancyWeeks: 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 weeks = pregnancyWeeks { + updateData["pregnancyWeeks"] = weeks + } + + 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) }, + pregnancyWeeks: data["pregnancyWeeks"] 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..3b3818e --- /dev/null +++ b/Tiny/Core/Services/Storage/FirebaseStorageService.swift @@ -0,0 +1,203 @@ +// +// 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 URL(string: downloadURL) != nil else { + throw StorageError.invalidURL + } + + // Create local file path with timestamp-based name (matching original format) + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let timeInterval = timestamp.timeIntervalSince1970 + 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: - Upload Moment Image + + func uploadMomentImage( + localFileURL: URL, + motherUserId: String, + momentId: String + ) async throws -> String { + let storageRef = storage.reference() + let momentRef = storageRef.child("moments/\(motherUserId)/\(momentId).jpg") + + let data = try Data(contentsOf: localFileURL) + + let metadata = StorageMetadata() + metadata.contentType = "image/jpeg" + metadata.customMetadata = [ + "motherUserId": motherUserId, + "momentId": momentId, + "uploadedAt": ISO8601DateFormatter().string(from: Date()) + ] + + _ = try await momentRef.putDataAsync(data, metadata: metadata) + let downloadURL = try await momentRef.downloadURL() + + return downloadURL.absoluteString + } + + // MARK: - Download Moment Image + + func downloadMomentImage( + downloadURL: String, + momentId: String, + timestamp: Date + ) async throws -> URL { + guard URL(string: downloadURL) != nil else { + throw StorageError.invalidURL + } + + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let timeInterval = timestamp.timeIntervalSince1970 + let fileName = "moment-\(timeInterval).jpg" + let localURL = documentsPath.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: localURL.path) { + return localURL + } + + let storageRef = storage.reference(forURL: downloadURL) + _ = try await storageRef.writeAsync(toFile: localURL) + + return localURL + } + + // MARK: - Delete Moment Image + + func deleteMomentImage(downloadURL: String) async throws { + let storageRef = storage.reference(forURL: downloadURL) + try await storageRef.delete() + } + + // MARK: - Get Shared Heartbeats for Room + + /// Lists all heartbeats shared in a room + /// - 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..bb0b8da --- /dev/null +++ b/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift @@ -0,0 +1,550 @@ +// +// HeartbeatSyncManager.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 27/11/25. +// + +import Foundation +import FirebaseFirestore +import SwiftData +internal import Combine + +// swiftlint:disable type_body_length +@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 + var 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()) + ] + + if let displayName = heartbeat.displayName { + metadata["displayName"] = displayName + } + + 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: - Update Heartbeat Name + + /// Updates the display name of a heartbeat in Firestore + func updateHeartbeatName(_ heartbeat: SavedHeartbeat, newName: String) async throws { + guard let firebaseId = heartbeat.firebaseId else { + throw SyncError.notSynced + } + + print("📝 Updating heartbeat name in Firestore...") + print(" ID: \(firebaseId)") + print(" New Name: \(newName)") + + // Update Firestore + try await dbf.collection("heartbeats").document(firebaseId).updateData([ + "displayName": newName + ]) + + // Update local model + heartbeat.displayName = newName + print("✅ Heartbeat name updated in Firestore") + } + + // MARK: - Unshare Heartbeat + + /// Marks a heartbeat as not shared + 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, + displayName: data["displayName"] as? String + ) + + 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, + displayName: data["displayName"] as? String + ) + } + + 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 + ) + + // Set display name if available + if let displayName = metadata.displayName { + heartbeat.displayName = displayName + } + + modelContext.insert(heartbeat) + syncedHeartbeats.append(heartbeat) + print(" ✅ Saved heartbeat to SwiftData: \(metadata.id)") + } catch { + print(" ❌ Failed to download heartbeat \(metadata.id): \(error)") + } + } + } + + try modelContext.save() + print("✅ Sync complete: \(syncedHeartbeats.count) heartbeats") + + return syncedHeartbeats + } + + // MARK: - Moment Sync + + func uploadMoment( + _ moment: SavedMoment, + motherUserId: String, + roomCode: String + ) async throws { + isSyncing = true + defer { isSyncing = false } + + // 1. Reconstruct full path from filename (since we store relative paths) + let fileName = URL(fileURLWithPath: moment.filePath).lastPathComponent + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let localURL = documentsPath.appendingPathComponent(fileName) + let momentId = moment.id.uuidString + + print("📤 Uploading moment...") + print(" Local file: \(localURL.path)") + + let downloadURL = try await storageService.uploadMomentImage( + localFileURL: localURL, + motherUserId: motherUserId, + momentId: momentId + ) + + // 2. Update local model + moment.firebaseStorageURL = downloadURL + moment.isSyncedToCloud = true + moment.motherUserId = motherUserId + moment.roomCode = roomCode + + // 3. Save metadata to Firestore + let metadata: [String: Any] = [ + "momentId": momentId, + "motherUserId": motherUserId, + "roomCode": roomCode, + "firebaseStorageURL": downloadURL, + "timestamp": Timestamp(date: moment.timestamp), + "isShared": moment.isShared, + "pregnancyWeeks": moment.pregnancyWeeks ?? 0, + "createdAt": Timestamp(date: Date()) + ] + + let docRef = try await dbf.collection("moments").addDocument(data: metadata) + moment.firebaseId = docRef.documentID + + print("✅ Moment metadata saved to Firestore") + } + + func deleteMoment(_ moment: SavedMoment) async throws { + // Delete from Storage if synced + if let storageURL = moment.firebaseStorageURL { + try await storageService.deleteMomentImage(downloadURL: storageURL) + } + + // Delete from Firestore + if let firebaseId = moment.firebaseId { + try await dbf.collection("moments").document(firebaseId).delete() + } + } + + func fetchAllMomentsForRoom(roomCode: String) async throws -> [MomentMetadata] { + print("🔍 Fetching ALL moments for room code: '\(roomCode)'") + + let snapshot = try await dbf.collection("moments") + .whereField("roomCode", isEqualTo: roomCode) + .getDocuments() + + let moments = snapshot.documents.compactMap { doc -> MomentMetadata? in + let data = doc.data() + + return MomentMetadata( + id: doc.documentID, + momentId: data["momentId"] as? String ?? "", + motherUserId: data["motherUserId"] as? String ?? "", + roomCode: data["roomCode"] as? String ?? "", + firebaseStorageURL: data["firebaseStorageURL"] as? String ?? "", + timestamp: (data["timestamp"] as? Timestamp)?.dateValue() ?? Date(), + isShared: data["isShared"] as? Bool ?? false, + pregnancyWeeks: data["pregnancyWeeks"] as? Int + ) + } + + return moments.sorted { $0.timestamp > $1.timestamp } + } + + func downloadMoment(metadata: MomentMetadata) async throws -> URL { + return try await storageService.downloadMomentImage( + downloadURL: metadata.firebaseStorageURL, + momentId: metadata.momentId, + timestamp: metadata.timestamp + ) + } + + func syncMomentsFromCloud( + roomCode: String, + modelContext: ModelContext + ) async throws -> [SavedMoment] { + print("🔄 Syncing moments from cloud...") + + let metadataList = try await fetchAllMomentsForRoom(roomCode: roomCode) + var syncedMoments: [SavedMoment] = [] + + for metadata in metadataList { + // Check if we already have this moment locally + let allMoments = try modelContext.fetch(FetchDescriptor()) + let existingMoment = allMoments.first { $0.firebaseId == metadata.id } + + if let existing = existingMoment { + // Fix: Check existence using filename only + let storedPath = existing.filePath + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let localURL = documentsPath.appendingPathComponent(fileName) + + if !FileManager.default.fileExists(atPath: localURL.path) { + // Re-download if file is missing + do { + let downloadedURL = try await downloadMoment(metadata: metadata) + // Update to relative path (filename) + existing.filePath = downloadedURL.lastPathComponent + } catch { + print("❌ Re-download moment failed: \(error)") + } + } else { + // If file exists but path was absolute, update to relative for consistency + if existing.filePath != fileName { + existing.filePath = fileName + } + } + syncedMoments.append(existing) + } else { + // Download new moment + do { + let localURL = try await downloadMoment(metadata: metadata) + + let moment = SavedMoment( + filePath: localURL.lastPathComponent, // Store relative path + timestamp: metadata.timestamp, + pregnancyWeeks: metadata.pregnancyWeeks, + firebaseId: metadata.id, + motherUserId: metadata.motherUserId, + roomCode: metadata.roomCode, + isShared: metadata.isShared, + firebaseStorageURL: metadata.firebaseStorageURL, + isSyncedToCloud: true + ) + + modelContext.insert(moment) + syncedMoments.append(moment) + print(" ✅ Saved moment to SwiftData: \(metadata.id)") + } catch { + print(" ❌ Failed to download moment \(metadata.id): \(error)") + } + } + } + + try modelContext.save() + print("✅ Moment sync complete: \(syncedMoments.count) moments") + + return syncedMoments + } +} +// swiftlint:enable type_body_length + +// MARK: - Supporting Types + +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? + let displayName: String? +} + +struct MomentMetadata: Identifiable { + let id: String + let momentId: String + let motherUserId: String + let roomCode: String + let firebaseStorageURL: String + let timestamp: Date + let isShared: Bool + let pregnancyWeeks: Int? +} + +enum SyncError: LocalizedError { + case notSynced + case uploadFailed + case downloadFailed + + var errorDescription: String? { + switch self { + case .notSynced: + return "Item has not been synced to cloud yet" + case .uploadFailed: + return "Failed to upload item" + case .downloadFailed: + return "Failed to download item" + } + } +} diff --git a/Tiny/Core/Services/Theme/ThemeManager.swift b/Tiny/Core/Services/Theme/ThemeManager.swift new file mode 100644 index 0000000..fa3a233 --- /dev/null +++ b/Tiny/Core/Services/Theme/ThemeManager.swift @@ -0,0 +1,51 @@ +// +// ThemeManager.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 28/11/25. +// + +import Foundation +import SwiftUI +internal import Combine + +class ThemeManager: ObservableObject { + @Published var selectedOrbStyle: OrbStyles { + didSet { + saveOrbStyle() + } + } + + @Published var selectedBackground: BackgroundTheme { + didSet { + saveBackground() + } + } + + private let orbStyleKey = "selectedOrbStyle" + private let backgroundKey = "selectedBackground" + + init() { + if let savedOrbStyle = UserDefaults.standard.string(forKey: orbStyleKey), + let orbStyle = OrbStyles(rawValue: savedOrbStyle) { + self.selectedOrbStyle = orbStyle + } else { + self.selectedOrbStyle = .yellow + } + + if let savedBackground = UserDefaults.standard.string(forKey: backgroundKey), + let background = BackgroundTheme(rawValue: savedBackground) { + self.selectedBackground = background + } else { + self.selectedBackground = .purple + } + } + + private func saveOrbStyle() { + UserDefaults.standard.set(selectedOrbStyle.rawValue, forKey: orbStyleKey) + } + + private func saveBackground() { + UserDefaults.standard.set(selectedBackground.rawValue, forKey: backgroundKey) + } +} diff --git a/Tiny/Core/Views/ImagePicker.swift b/Tiny/Core/Views/ImagePicker.swift new file mode 100644 index 0000000..5421de0 --- /dev/null +++ b/Tiny/Core/Views/ImagePicker.swift @@ -0,0 +1,48 @@ +// +// ImagePicker.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import SwiftUI +import UIKit + +struct ImagePicker: UIViewControllerRepresentable { + @Binding var image: UIImage? + @Environment(\.presentationMode) private var presentationMode + var sourceType: UIImagePickerController.SourceType = .photoLibrary + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = sourceType + picker.delegate = context.coordinator + picker.allowsEditing = false + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let uiImage = info[.originalImage] as? UIImage { + parent.image = uiImage + } + parent.presentationMode.wrappedValue.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.presentationMode.wrappedValue.dismiss() + } + } +} diff --git a/Tiny/Features/Authentication/Views/NameInputView.swift b/Tiny/Features/Authentication/Views/NameInputView.swift new file mode 100644 index 0000000..50a539d --- /dev/null +++ b/Tiny/Features/Authentication/Views/NameInputView.swift @@ -0,0 +1,123 @@ +// +// NameInputView.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 27/11/25. +// + +import SwiftUI + +struct NameInputView: View { + @EnvironmentObject var themeManager: ThemeManager + @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 { + Color.black.ignoresSafeArea() + Image(themeManager.selectedBackground.imageName) + .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)) + // For mothers, proceed to week input before setting role + // For fathers, set role now and proceed to room code input + if selectedRole == .father { + try await authService.updateUserRole(role: selectedRole) + } + await MainActor.run { + isLoading = false + onContinue() + } + } catch { + await MainActor.run { + errorMessage = error.localizedDescription + 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..82cfb87 --- /dev/null +++ b/Tiny/Features/Authentication/Views/OnboardingCoordinator.swift @@ -0,0 +1,66 @@ +// +// OnboardingCoordinator.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 27/11/25. +// + +import SwiftUI + +enum OnboardingStep { + case roleSelection + case nameInput(role: UserRole) + case weekInput // For mothers only + case roomCodeInput // For fathers only +} + +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 == .mother { + currentStep = .weekInput + } else { + currentStep = .roomCodeInput + } + } + ) + case .weekInput: + WeekInputView(onComplete: { week in + Task { + do { + // Update user role and pregnancy week in Firebase + try await authService.updateUserRole(role: .mother, pregnancyWeeks: week) + print("✅ Successfully saved pregnancy week: \(week)") + } catch { + print("❌ Error saving pregnancy week: \(error.localizedDescription)") + } + } + }) + 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..571917d --- /dev/null +++ b/Tiny/Features/Authentication/Views/RoleSelectionView.swift @@ -0,0 +1,94 @@ +// +// RoleSelectionView.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 26/11/25. +// + +import SwiftUI + +struct RoleSelectionView: View { + @EnvironmentObject var themeManager: ThemeManager + @EnvironmentObject var authService: AuthenticationService + @Binding var selectedRole: UserRole? + let onContinue: () -> Void + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + Image(themeManager.selectedBackground.imageName) + .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..7336905 --- /dev/null +++ b/Tiny/Features/Authentication/Views/RoomCodeInputView.swift @@ -0,0 +1,134 @@ +// +// RoomCodeInputView.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 27/11/25. +// + +import SwiftUI + +struct RoomCodeInputView: View { + @EnvironmentObject var themeManager: ThemeManager + @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 { + Color.black.ignoresSafeArea() + Image(themeManager.selectedBackground.imageName) + .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..1f8ba0e --- /dev/null +++ b/Tiny/Features/Authentication/Views/SignInView.swift @@ -0,0 +1,93 @@ +// +// SignInView.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 26/11/25. +// + +import SwiftUI +import AuthenticationServices + +struct SignInView: View { + @EnvironmentObject var themeManager: ThemeManager + @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) { + Color.black.ignoresSafeArea() + Image(themeManager.selectedBackground.imageName) + .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/Customization/Views/ThemeCustomizationView.swift b/Tiny/Features/Customization/Views/ThemeCustomizationView.swift new file mode 100644 index 0000000..8efc821 --- /dev/null +++ b/Tiny/Features/Customization/Views/ThemeCustomizationView.swift @@ -0,0 +1,213 @@ +// +// ThemeCustomizationView.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 28/11/25. +// + +import SwiftUI + +struct ThemeCustomizationView: View { + @EnvironmentObject var themeManager: ThemeManager + + @State private var selectedTab: CustomizationTab = .sphere + + enum CustomizationTab: String, CaseIterable { + case sphere = "Sphere" + case background = "Background" + } + + var body: some View { + ZStack { + // Background + Color.black.ignoresSafeArea() + Image(themeManager.selectedBackground.imageName) + .resizable() + .ignoresSafeArea() + .opacity(1) + + VStack(spacing: 0) { + // Preview Orb - Centered in remaining space + Spacer() + + ZStack { + AnimatedOrbView(size: 240) + .environmentObject(themeManager) + + BokehEffectView(amplitude: .constant(0.6)) + .environmentObject(themeManager) + } + .frame(width: 240, height: 240) + + Spacer() + + // Bottom Sheet + VStack(spacing: 0) { + // Segmented Control + HStack(spacing: 0) { + ForEach(CustomizationTab.allCases, id: \.self) { tab in + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + selectedTab = tab + } + }, label: { + Text(tab.rawValue) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(selectedTab == tab ? .white : .tinyViolet) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(selectedTab == tab ? Color.tinyViolet : Color.clear) + ) + }) + .buttonStyle(.plain) + } + } + .padding(6) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(Color.white.opacity(0.1)) + ) + .padding(.horizontal, 24) + .padding(.top, 24) + + // Options - Horizontal scroll with selected item prominent + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + if selectedTab == .sphere { + ForEach(OrbStyles.allCases) { style in + OrbOptionButton( + style: style, + isSelected: themeManager.selectedOrbStyle == style, + action: { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + themeManager.selectedOrbStyle = style + } + } + ) + .id(style.id) + } + } else { + ForEach(BackgroundTheme.allCases) { background in + BackgroundOptionButton( + background: background, + isSelected: themeManager.selectedBackground == background, + action: { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + themeManager.selectedBackground = background + } + } + ) + .id(background.id) + } + } + } + .padding(.horizontal, 24) + .padding(.vertical, 30) + } + .onChange(of: themeManager.selectedOrbStyle) { _, newValue in + withAnimation { + proxy.scrollTo(newValue.id, anchor: .center) + } + } + .onChange(of: themeManager.selectedBackground) { _, newValue in + withAnimation { + proxy.scrollTo(newValue.id, anchor: .center) + } + } + } + .frame(height: 240) + } + .background( + RoundedRectangle(cornerRadius: 32) + .fill(Color.white.opacity(0.05)) + .background( + RoundedRectangle(cornerRadius: 32) + .fill(.ultraThinMaterial.opacity(0.07)) + ) + .ignoresSafeArea(.all) + ) + .padding(.bottom, 0) + } + } + .navigationTitle("Theme") + .navigationBarTitleDisplayMode(.inline) + .preferredColorScheme(.dark) + } +} + +// Orb Option Button - Selected is MUCH larger +struct OrbOptionButton: View { + let style: OrbStyles + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + ZStack { + // The orb itself + AnimatedOrbView(size: isSelected ? 120 : 90, style: style) + + // Subtle glow for selected + if isSelected { + Circle() + .fill(style.glowColor.opacity(0.2)) + .frame(width: 160, height: 160) + .blur(radius: 20) + } + } + .frame(width: isSelected ? 160 : 100, height: isSelected ? 160 : 100) + .opacity(isSelected ? 1 : 0.3) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: isSelected) + } +} + +// Background Option Button - Selected is MUCH larger +struct BackgroundOptionButton: View { + let background: BackgroundTheme + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + ZStack { + // Background preview circle + Circle() + .fill( + ImagePaint( + image: Image(background.imageName), + scale: isSelected ? 0.25 : 0.35 + ) + ) + .frame(width: isSelected ? 120 : 90, height: isSelected ? 120 : 90) + + // Border for selected + if isSelected { + Circle() + .stroke(Color.white.opacity(0.6), lineWidth: 3) + .frame(width: isSelected ? 120 : 95, height: isSelected ? 120 : 95) + } + + // Subtle outer glow + if isSelected { + Circle() + .stroke(Color.white.opacity(0.2), lineWidth: 1) + .frame(width: 120, height: 120) + .blur(radius: 10) + } + } + .frame(width: isSelected ? 120 : 100, height: isSelected ? 120 : 100) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: isSelected) + } +} + +#Preview { + ThemeCustomizationView() + .environmentObject(ThemeManager()) +} diff --git a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift index f8a73c3..f68fc2e 100644 --- a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift +++ b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift @@ -10,18 +10,32 @@ import SwiftData internal import Combine class HeartbeatMainViewModel: ObservableObject { - @Published var showTimeline = false + @Published var currentPage: Int = 0 + @Published var allowTabViewSwipe: Bool = true + @Published var selectedRecording: Recording? 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) { - heartbeatSoundManager.lastRecording = recording + print("🎵 Recording selected: \(recording.fileURL.lastPathComponent)") + + // Show SavedRecordingPlaybackView withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { - showTimeline = false + selectedRecording = recording } } } diff --git a/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift b/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift index 8866098..1c2656f 100644 --- a/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift +++ b/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift @@ -19,10 +19,20 @@ class OrbLiveListenViewModel: ObservableObject { @Published var longPressScale: CGFloat = 1.0 @Published var dragOffset: CGFloat = 0 @Published var isDraggingToSave = false + @Published var isDraggingToDelete = false @Published var saveButtonScale: CGFloat = 1.0 + @Published var deleteButtonScale: CGFloat = 1.0 @Published var orbDragScale: CGFloat = 1.0 @Published var canSaveCurrentRecording = false @Published var currentTime: TimeInterval = 0 + + var isHapticsEnabled: Bool { + audioPostProcessingManager.isHapticsEnabled + } + + func toggleHaptics() { + audioPostProcessingManager.toggleHaptics() + } private var longPressTimer: Timer? private var playbackTimer: Timer? @@ -60,6 +70,7 @@ class OrbLiveListenViewModel: ObservableObject { } func setupPlayback(for recording: Recording) { + // This is only called for fresh recordings after countdown isPlaybackMode = true animateOrb = true audioPostProcessingManager.stop() @@ -68,30 +79,55 @@ class OrbLiveListenViewModel: ObservableObject { } func handleDragChange(value: SequenceGesture.Value, geometry: GeometryProxy) { - guard canSaveCurrentRecording else { return } + guard canSaveCurrentRecording || isPlaybackMode else { return } switch value { case .second(true, let drag): - isDraggingToSave = true - let translation = max(0, drag?.translation.height ?? 0) - dragOffset = translation - let maxDragDistance = geometry.size.height / 2 - let dragProgress = min(translation / maxDragDistance, 1.0) - withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.7)) { - orbDragScale = 1.0 - (dragProgress * 0.4) - saveButtonScale = 1.0 + (dragProgress * 0.4) + let translation = drag?.translation.height ?? 0 + + if translation > 0 { + // Dragging DOWN - Save + isDraggingToSave = true + isDraggingToDelete = false + dragOffset = translation + let maxDragDistance = geometry.size.height / 2 + let dragProgress = min(translation / maxDragDistance, 1.0) + withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.7)) { + orbDragScale = 1.0 - (dragProgress * 0.4) + saveButtonScale = 1.0 + (dragProgress * 0.4) + deleteButtonScale = 1.0 + } + } else if translation < 0 { + // Dragging UP - Delete + isDraggingToDelete = true + isDraggingToSave = false + dragOffset = translation + let maxDragDistance = geometry.size.height / 2 + let dragProgress = min(abs(translation) / maxDragDistance, 1.0) + withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.7)) { + orbDragScale = 1.0 - (dragProgress * 0.4) + deleteButtonScale = 1.0 + (dragProgress * 0.4) + saveButtonScale = 1.0 + } } default: break } } - func handleDragEnd(value: SequenceGesture.Value, geometry: GeometryProxy, onSave: @escaping () -> Void) { - guard canSaveCurrentRecording else { return } + func handleDragEnd(value: SequenceGesture.Value, geometry: GeometryProxy, onSave: @escaping () -> Void, onDelete: @escaping () -> Void) { + guard canSaveCurrentRecording || isPlaybackMode else { return } switch value { case .second(true, let drag): let translation = drag?.translation.height ?? 0 - if translation > geometry.size.height / 4 { + let threshold = geometry.size.height / 4 + + if translation > threshold { + // Dragged down enough - Save handleSaveRecording(onSave: onSave) + } else if translation < -threshold { + // Dragged up enough - Delete/Dismiss + handleDeleteRecording(onDelete: onDelete) } else { + // Not dragged enough - Reset resetDragState() } default: resetDragState() @@ -103,7 +139,9 @@ class OrbLiveListenViewModel: ObservableObject { dragOffset = 0 orbDragScale = 1.0 saveButtonScale = 1.0 + deleteButtonScale = 1.0 isDraggingToSave = false + isDraggingToDelete = false } } @@ -113,10 +151,41 @@ class OrbLiveListenViewModel: ObservableObject { saveButtonScale = 1.6 orbDragScale = 0.05 } - onSave() - canSaveCurrentRecording = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.resetDragState() + + // Wait for animation to complete before saving and navigating + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + onSave() + self.canSaveCurrentRecording = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.resetDragState() + } + } + } + + func handleDeleteRecording(onDelete: @escaping () -> Void) { + withAnimation(.interpolatingSpring(mass: 1, stiffness: 200, damping: 15)) { + deleteButtonScale = 1.6 + orbDragScale = 0.05 + } + + // Wait for animation to complete before deleting and navigating + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + // Call the delete callback + onDelete() + + // Stop playback and reset state + self.audioPostProcessingManager.stop() + self.stopPlaybackTimer() + self.canSaveCurrentRecording = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + self.isPlaybackMode = false + self.animateOrb = false + self.resetDragState() + } + } } } @@ -133,6 +202,7 @@ class OrbLiveListenViewModel: ObservableObject { func handleDoubleTap(onStart: @escaping () -> Void) { guard !isLongPressing, !isListening, !isPlaybackMode else { return } + withAnimation(.interpolatingSpring(mass: 2, stiffness: 100, damping: 20)) { animateOrb = true isListening = true @@ -222,31 +292,29 @@ class OrbLiveListenViewModel: ObservableObject { } func handleLongPressComplete(onStop: @escaping () -> Void) { - cancelLongPressCountdown() + // Stop the timer but don't trigger separate animation + longPressTimer?.invalidate() + longPressTimer = nil + longPressCountdown = 3 - withAnimation(.interpolatingSpring(mass: 2, stiffness: 100, damping: 20)) { + // 1. First animation: Move orb to center and reset scale + withAnimation(.spring(response: 0.8, dampingFraction: 0.8)) { isListening = false + isLongPressing = false + longPressScale = 1.0 animateOrb = false - isPlaybackMode = true - canSaveCurrentRecording = true } onStop() - } - - func handleSelectRecordingFromTimeline(_ recording: Recording, onSelect: @escaping (Recording) -> Void) { - isListening = false - withAnimation(.easeInOut(duration: 0.4)) { - isPlaybackMode = true - canSaveCurrentRecording = false - animateOrb = true + // 2. Second phase: Switch to playback mode after movement starts + // We delay this slightly to prevent the GestureModifier from reconstructing the view + // and cancelling the spring animation mid-flight. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + withAnimation(.easeIn(duration: 0.3)) { + self.isPlaybackMode = true + self.canSaveCurrentRecording = true + } } - - audioPostProcessingManager.stop() - audioPostProcessingManager.loadAndPlay(fileURL: recording.fileURL) - startPlaybackTimer() - - onSelect(recording) } } diff --git a/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift b/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift new file mode 100644 index 0000000..7d6b6b0 --- /dev/null +++ b/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift @@ -0,0 +1,243 @@ +// +// SavedRecordingPlaybackViewModel.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import Foundation +import SwiftUI +internal import Combine +import AudioKit +import SwiftData + +@MainActor +class SavedRecordingPlaybackViewModel: ObservableObject { + @Published var isPlaying = false + @Published var currentTime: TimeInterval = 0 + @Published var duration: TimeInterval = 0 + @Published var recordingName = "Heartbeat Recording" + @Published var editedName = "Heartbeat Recording" + @Published var isEditingName = false + @Published var showSuccessAlert = false + @Published var formattedDate = "" + @Published var showShareSheet = false + + // Drag state + @Published var dragOffset: CGFloat = 0 + @Published var orbDragScale: CGFloat = 1.0 + @Published var isDraggingToDelete = false + @Published var deleteButtonScale: CGFloat = 1.0 + + private var audioManager: HeartbeatSoundManager? + private var currentRecording: Recording? + private var modelContext: ModelContext? + private var onRecordingUpdated: (() -> Void)? + + let audioPostProcessingManager = AudioPostProcessingManager() + private var cancellables = Set() + + var isHapticsEnabled: Bool { + audioPostProcessingManager.isHapticsEnabled + } + + init() { + // Subscribe to audioPostProcessingManager changes to trigger UI updates + audioPostProcessingManager.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.isPlaying = self.audioPostProcessingManager.isPlaying + self.currentTime = self.audioPostProcessingManager.currentTime + self.duration = self.audioPostProcessingManager.duration + } + .store(in: &cancellables) + } + + func toggleHaptics() { + audioPostProcessingManager.toggleHaptics() + objectWillChange.send() + } + + func setupPlayback(for recording: Recording, manager: HeartbeatSoundManager, modelContext: ModelContext, onRecordingUpdated: @escaping () -> Void) { + self.audioManager = manager + self.currentRecording = recording + self.modelContext = modelContext + self.onRecordingUpdated = onRecordingUpdated + + // Try to find the SavedHeartbeat entry to get custom name + let filePath = recording.fileURL.path + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.filePath == filePath } + ) + + if let savedHeartbeat = try? modelContext.fetch(descriptor).first { + // Use custom name if available, otherwise use filename + if let customName = savedHeartbeat.displayName, !customName.isEmpty { + self.recordingName = customName + } else { + let fileName = recording.fileURL.deletingPathExtension().lastPathComponent + self.recordingName = fileName.replacingOccurrences(of: "recording-", with: "Recording ") + } + } else { + // Fallback to filename + let fileName = recording.fileURL.deletingPathExtension().lastPathComponent + self.recordingName = fileName.replacingOccurrences(of: "recording-", with: "Recording ") + } + + self.editedName = recordingName + + // Format date + let formatter = DateFormatter() + formatter.dateFormat = "d MMMM yyyy" + self.formattedDate = formatter.string(from: recording.createdAt) + + // Stop any existing playback in manager + manager.stop() + + // Start playback with AudioPostProcessingManager + audioPostProcessingManager.loadAndPlay(fileURL: recording.fileURL) + } + + func togglePlayback(manager: HeartbeatSoundManager, recording: Recording) { + if audioPostProcessingManager.isPlaying { + audioPostProcessingManager.pause() + } else { + if audioPostProcessingManager.currentTime > 0 { + audioPostProcessingManager.resume() + } else { + audioPostProcessingManager.loadAndPlay(fileURL: recording.fileURL) + } + } + } + + func cleanup() { + audioPostProcessingManager.stop() + } + + func handleDragChange(value: DragGesture.Value, geometry: GeometryProxy) { + let translation = value.translation.height + dragOffset = translation + + // Only handle upward drag (delete) + if translation < 0 { + isDraggingToDelete = true + let progress = min(abs(translation) / (geometry.size.height / 4), 1.0) + orbDragScale = 1.0 - (progress * 0.3) + deleteButtonScale = 1.0 + (progress * 0.6) + } else { + resetDragState() + } + } + + func handleDragEnd(value: DragGesture.Value, geometry: GeometryProxy, onDelete: @escaping () -> Void) { + let translation = value.translation.height + let threshold = geometry.size.height / 4 + + if translation < -threshold { + // Dragged up enough - Delete + handleDelete(onDelete: onDelete) + } else { + // Not dragged enough - Reset + resetDragState() + } + } + + private func handleDelete(onDelete: @escaping () -> Void) { + withAnimation(.interpolatingSpring(mass: 1, stiffness: 200, damping: 15)) { + deleteButtonScale = 1.6 + orbDragScale = 0.05 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + onDelete() + } + } + + private func resetDragState() { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + dragOffset = 0 + orbDragScale = 1.0 + isDraggingToDelete = false + deleteButtonScale = 1.0 + } + } + + func startEditing() { + isEditingName = true + } + + func saveName() { + recordingName = editedName + isEditingName = false + + // Show success alert + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = true + } + + // Hide alert after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + self.showSuccessAlert = false + } + } + + // Save to SwiftData + guard let recording = currentRecording, + let modelContext = modelContext else { + print("❌ Cannot save: missing recording or context") + return + } + + let filePath = recording.fileURL.path + print("🔍 Looking for SavedHeartbeat with path: \(filePath)") + + let descriptor = FetchDescriptor( + predicate: #Predicate { heartbeat in + heartbeat.filePath == filePath + } + ) + + do { + let results = try modelContext.fetch(descriptor) + print("📊 Found \(results.count) matching recordings") + + if let savedHeartbeat = results.first { + print("✏️ Updating recording: \(savedHeartbeat.filePath)") + print(" Old name: \(savedHeartbeat.displayName ?? "nil")") + print(" New name: \(editedName)") + + savedHeartbeat.displayName = editedName + try modelContext.save() + + print("✅ Saved recording name: \(editedName)") + + // Trigger reload to update UI + onRecordingUpdated?() + + // Sync to Firebase + if let syncManager = self.audioManager?.syncManager { + Task { + do { + try await syncManager.updateHeartbeatName(savedHeartbeat, newName: editedName) + } catch { + print("❌ Failed to sync name update: \(error)") + } + } + } + } else { + print("⚠️ Could not find SavedHeartbeat entry for path: \(filePath)") + // List all recordings for debugging + let allDescriptor = FetchDescriptor() + let allRecordings = try modelContext.fetch(allDescriptor) + print("📝 All recordings in database:") + for (index, rec) in allRecordings.enumerated() { + print(" [\(index)] \(rec.filePath)") + } + } + } catch { + print("❌ Error saving recording name: \(error)") + } + } +} diff --git a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift index 40d2862..d25f32a 100644 --- a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift +++ b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift @@ -11,27 +11,158 @@ 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 + + private var isMother: Bool { + authService.currentUser?.role == .mother + } var body: some View { ZStack { - if viewModel.showTimeline { + TabView(selection: $viewModel.currentPage) { PregnancyTimelineView( heartbeatSoundManager: viewModel.heartbeatSoundManager, - showTimeline: $viewModel.showTimeline, - onSelectRecording: viewModel.handleRecordingSelection + showTimeline: .constant(true), + onSelectRecording: viewModel.handleRecordingSelection, + onDisableSwipe: { disable in + print("🚫 Swipe disable requested: \(disable), allowTabViewSwipe will be: \(!disable)") + viewModel.allowTabViewSwipe = !disable + }, + isMother: isMother, + inputWeek: authService.currentUser?.pregnancyWeeks ) - .transition(.opacity) - } else { + .tag(0) + .transition(.asymmetric( + insertion: .scale(scale: 0.95).combined(with: .opacity), + removal: .scale(scale: 1.05).combined(with: .opacity) + )) OrbLiveListenView( heartbeatSoundManager: viewModel.heartbeatSoundManager, - showTimeline: $viewModel.showTimeline + showTimeline: Binding( + get: { viewModel.currentPage == 0 }, + set: { if $0 { viewModel.currentPage = 0 } else { viewModel.currentPage = 1 } } + ) ) - .transition(.opacity) + .tag(1) + .transition(.asymmetric( + insertion: .scale(scale: 0.95).combined(with: .opacity), + removal: .scale(scale: 1.05).combined(with: .opacity) + )) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .highPriorityGesture( + // Block TabView swipe only on Orb View (page 1) when requested (e.g. recording/dragging) + // On Timeline View (page 0), we allow gestures to pass through so vertical scrolling (Profile/Tutorial) works + (viewModel.currentPage == 1 && !viewModel.allowTabViewSwipe) ? DragGesture() : nil + ) + .ignoresSafeArea() + + // Page indicator dots + PageIndicators(viewModel: viewModel, manager: viewModel.heartbeatSoundManager) + + // SavedRecordingPlaybackView overlay + if let recording = viewModel.selectedRecording { + SavedRecordingPlaybackView( + recording: recording, + heartbeatSoundManager: viewModel.heartbeatSoundManager, + showTimeline: Binding( + get: { false }, + set: { if $0 { viewModel.selectedRecording = nil } } + ) + ) + .transition(.move(edge: .trailing).combined(with: .opacity)) + .zIndex(10) } } .preferredColorScheme(.dark) .onAppear { - viewModel.setupManager(modelContext: modelContext) + // 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) + } + } + + 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 + } + } + } +} + +private struct PageIndicators: View { + @ObservedObject var viewModel: HeartbeatMainViewModel + @ObservedObject var manager: HeartbeatSoundManager + + var body: some View { + if manager.isRecording || manager.isPlayingPlayback || viewModel.selectedRecording != nil || !viewModel.allowTabViewSwipe { + EmptyView() + } else { + VStack { + Spacer() + HStack(spacing: 8) { + // Timeline dot (page 0) + Circle() + .fill(viewModel.currentPage == 0 ? Color.white : Color.white.opacity(0.3)) + .frame(width: 8, height: 8) + .animation(.easeInOut(duration: 0.2), value: viewModel.currentPage) + + // Orb dot (page 1) + Circle() + .fill(viewModel.currentPage == 1 ? Color.white : Color.white.opacity(0.3)) + .frame(width: 8, height: 8) + .animation(.easeInOut(duration: 0.2), value: viewModel.currentPage) + } + .padding(.bottom, 20) + } + .transition(.opacity) } } } @@ -39,4 +170,7 @@ struct HeartbeatMainView: View { #Preview { HeartbeatMainView() .modelContainer(for: SavedHeartbeat.self, inMemory: true) + .environmentObject(AuthenticationService()) + .environmentObject(HeartbeatSyncManager()) + .environmentObject(ThemeManager()) } diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index 74661ef..6c95853 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -1,12 +1,18 @@ import SwiftUI import SwiftData +// swiftlint:disable type_body_length struct OrbLiveListenView: View { + @State private var showThemeCustomization = false + @EnvironmentObject var themeManager: ThemeManager + + @State private var showSuccessAlert = false + @State private var successMessage = (title: "", subtitle: "") @Environment(\.modelContext) private var modelContext - + @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager @Binding var showTimeline: Bool - + @StateObject private var viewModel = OrbLiveListenViewModel() @StateObject private var tutorialViewModel = TutorialViewModel() @@ -16,20 +22,63 @@ struct OrbLiveListenView: View { GeometryReader { geometry in ZStack { backgroundView + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + topControlsView + .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete || showSuccessAlert ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + .animation(.easeOut(duration: 0.2), value: showSuccessAlert) + statusTextView + .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete || showSuccessAlert ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + .animation(.easeOut(duration: 0.2), value: showSuccessAlert) + orbView(geometry: geometry) // Save/Library Button (Only visible when dragging) saveButton(geometry: geometry) - // Floating Button to Open Timeline manually - if !viewModel.isListening && !viewModel.isDraggingToSave { - libraryOpenButton(geometry: geometry) - } + // Delete Button (Only visible when dragging up) + deleteButton(geometry: geometry) coachMarkView + // Success Alert (Slide down, no overlay) + if showSuccessAlert { + VStack { + HStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + Text(successMessage.title) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text(successMessage.subtitle) + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer() + } + .padding(20) + .glassEffect(.clear) + .padding(.horizontal, 20) + .padding(.top, 20) + + Spacer() + } + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(300) + } + if let context = tutorialViewModel.activeTutorial { TutorialOverlay(viewModel: tutorialViewModel, context: context) } @@ -39,6 +88,10 @@ struct OrbLiveListenView: View { ShareSheet(activityItems: [lastRecordingURL]) } } + .sheet(isPresented: $showThemeCustomization) { + ThemeCustomizationView() + .environmentObject(themeManager) + } .preferredColorScheme(.dark) .onAppear { tutorialViewModel.showInitialTutorialIfNeeded() @@ -50,7 +103,7 @@ struct OrbLiveListenView: View { private var backgroundView: some View { ZStack { Color.black.ignoresSafeArea() - Image("backgroundPurple") + Image(themeManager.selectedBackground.imageName) .resizable() .scaleEffect(viewModel.isListening ? 1.2 : 1.0) .animation(.easeInOut(duration: 1.2), value: viewModel.isListening) @@ -59,62 +112,51 @@ struct OrbLiveListenView: View { } private var topControlsView: some View { - VStack { - HStack { - if viewModel.isPlaybackMode { - Button(action: viewModel.handleBackButton, label: { - Image(systemName: "chevron.left") - .font(.system(size: 22, weight: .semibold)) - .foregroundColor(.white) - .frame(width: 50, height: 50) - .clipShape(Circle()) - }) - .glassEffect(.clear) - .transition(.opacity.animation(.easeInOut)) - } - Spacer() - } - .padding() - Spacer() - } - } - - private func libraryOpenButton(geometry: GeometryProxy) -> some View { - VStack { - HStack { - Spacer() + GeometryReader { _ in + VStack { + HStack { + if viewModel.isPlaybackMode { + Button(action: viewModel.handleBackButton, label: { + Image(systemName: "chevron.left") + .font(.system(size: 22, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 50, height: 50) + .clipShape(Circle()) + }) + .glassEffect(.clear) + .transition(.opacity.animation(.easeInOut)) + + Spacer() + + HStack { + Button { + viewModel.toggleHaptics() + } label: { + Image(systemName: "iphone.gen3.radiowaves.left.and.right") + .font(.body) + .foregroundColor(viewModel.isHapticsEnabled ? .white : .white.opacity(0.4)) + .frame(width: 50, height: 50) + } + .glassEffect(.clear) - if viewModel.isPlaybackMode { - Button { - viewModel.showShareSheet = true - } label: { - Image(systemName: "square.and.arrow.up") - .font(.body) - .foregroundColor(.white) - .frame(width: 50, height: 50) - .clipShape(Circle()) - } - .glassEffect(.clear) - .padding(.bottom, 50) - .transition(.opacity.animation(.easeInOut)) - } - - Button { - withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { - showTimeline = true + Button { + viewModel.showShareSheet = true + } label: { + Image(systemName: "square.and.arrow.up") + .font(.body) + .foregroundColor(.white) + .frame(width: 50, height: 50) + } + .glassEffect(.clear) + } + .transition(.opacity.animation(.easeInOut)) + } else { + Spacer() } - } label: { - Image(systemName: "book.fill") - .font(.body) - .foregroundColor(.white) - .frame(width: 50, height: 50) - .clipShape(Circle()) } - .glassEffect(.clear) - .padding(.bottom, 50) + .padding() + Spacer() } - .padding() - Spacer() } } @@ -123,30 +165,50 @@ struct OrbLiveListenView: View { .font(.system(size: 28)) .foregroundColor(.white) .frame(width: 77, height: 77) - .background(Circle().fill(Color.white.opacity(0.1))) .clipShape(Circle()) + .glassEffect(.clear) .scaleEffect(viewModel.saveButtonScale) - .position(x: geometry.size.width / 2, y: geometry.size.height - 100) + .position(x: geometry.size.width / 2, y: geometry.size.height - 46) .opacity(viewModel.isDraggingToSave ? min(viewModel.dragOffset / 150.0, 1.0) : 0.0) .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) .animation(.easeOut(duration: 0.2), value: viewModel.dragOffset) } + private func deleteButton(geometry: GeometryProxy) -> some View { + Image(systemName: "trash.fill") + .font(.system(size: 28)) + .foregroundColor(.red) + .frame(width: 77, height: 77) + .clipShape(Circle()) + .glassEffect(.clear) + .scaleEffect(viewModel.deleteButtonScale) + .position(x: geometry.size.width / 2, y: 50) + .opacity(viewModel.isDraggingToDelete ? min(abs(viewModel.dragOffset) / 150.0, 1.0) : 0.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + .animation(.easeOut(duration: 0.2), value: viewModel.dragOffset) + } + private var statusTextView: some View { VStack { Group { if viewModel.isListening && viewModel.isLongPressing { CountdownTextView(countdown: viewModel.longPressCountdown, isVisible: viewModel.isLongPressing) } else if viewModel.isListening { - Text("Listening...") - .font(.title) - .fontWeight(.bold) + VStack(spacing: 8) { + Text("Listening...") + .font(.title) + .fontWeight(.bold) + + Text("Hold sphere to stop session") + .font(.subheadline) + .foregroundStyle(.placeholder) + } } else if viewModel.isPlaybackMode { VStack(spacing: 8) { Text(viewModel.audioPostProcessingManager.isPlaying ? "Playing..." : (viewModel.isDraggingToSave ? "Drag to save" : "Tap orb to play")) - .font(.title2) - .fontWeight(.medium) + .font(.title2) + .fontWeight(.medium) if viewModel.audioPostProcessingManager.duration > 0 && !viewModel.isDraggingToSave { Text("\(Int(viewModel.currentTime))s / \(Int(viewModel.audioPostProcessingManager.duration))s") @@ -177,6 +239,7 @@ struct OrbLiveListenView: View { .animation(.easeInOut(duration: 0.2), value: viewModel.longPressScale) .animation(.easeInOut(duration: 0.2), value: viewModel.orbDragScale) .offset(y: viewModel.orbOffset(geometry: geometry) + viewModel.dragOffset) + .animation(.spring(response: 1.0, dampingFraction: 0.8), value: viewModel.isListening) .onTapGesture(count: 2) { viewModel.handleDoubleTap { heartbeatSoundManager.start() @@ -193,12 +256,43 @@ struct OrbLiveListenView: View { viewModel.handleDragChange(value: value, geometry: geometry) }, handleDragEnd: { value in - viewModel.handleDragEnd(value: value, geometry: geometry) { - heartbeatSoundManager.saveRecording() - withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { - showTimeline = true + viewModel.handleDragEnd(value: value, geometry: geometry, onSave: { + // Show alert first + successMessage = (title: "Saved!", subtitle: "Your recording is saved on timeline.") + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = true } - } + // Then save after alert is visible + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + heartbeatSoundManager.saveRecording() + // Navigate after another delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = false + showTimeline = true + } + } + } + }, onDelete: { + // Show alert first + successMessage = (title: "Deleted.", subtitle: "Your recording is deleted.") + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = true + } + // Then delete after alert is visible + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if let lastRecording = heartbeatSoundManager.lastRecording { + heartbeatSoundManager.deleteRecording(lastRecording) + } + // Navigate after another delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = false + showTimeline = true + } + } + } + }) }, handleLongPressChange: viewModel.handleLongPressChange, handleLongPressComplete: { @@ -235,12 +329,37 @@ struct OrbLiveListenView: View { if !viewModel.isListening && !viewModel.isPlaybackMode { GeometryReader { proxy in CoachMarkView() - .position(x: proxy.size.width / 2, y: proxy.size.height / 2 + 250) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2 + 230) } .transition(.opacity) } } } + + private var themeButton: some View { + VStack { + HStack { + if !viewModel.isListening && !viewModel.isPlaybackMode { + Button(action: { + showThemeCustomization = true + }, label: { + Image(systemName: "paintbrush.fill") + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 50, height: 50) + .background(Circle().fill(Color.white.opacity(0.1))) + .clipShape(Circle()) + }) + .padding(.leading, 16) + .padding(.top, 50) + .transition(.opacity.animation(.easeInOut)) + } + Spacer() + } + Spacer() + } + .allowsHitTesting(true) // Ensure button is tappable + } struct GestureModifier: ViewModifier { let isPlaybackMode: Bool @@ -249,9 +368,9 @@ struct OrbLiveListenView: View { let handleDragEnd: (SequenceGesture.Value) -> Void let handleLongPressChange: (Bool) -> Void let handleLongPressComplete: () -> Void - + @GestureState private var isDetectingLongPress = false - + func body(content: Content) -> some View { if isPlaybackMode { content.gesture( @@ -277,11 +396,31 @@ struct OrbLiveListenView: View { } } } +// swiftlint:enable type_body_length -#Preview { - OrbLiveListenView( - heartbeatSoundManager: HeartbeatSoundManager(), - showTimeline: .constant(false) +// #Preview("Normal Mode") { +// OrbLiveListenView( +// heartbeatSoundManager: HeartbeatSoundManager(), +// showTimeline: .constant(true) +// ) +// .environmentObject(ThemeManager()) +// .modelContainer(for: SavedHeartbeat.self, inMemory: true) +// } + +#Preview("Playback Mode") { + let manager = HeartbeatSoundManager() + + // Create a mock recording + let mockURL = URL(fileURLWithPath: "/mock/heartbeat-\(Date().timeIntervalSince1970).m4a") + let mockRecording = Recording(fileURL: mockURL, createdAt: Date()) + + // Set it as the last recording to trigger playback mode + manager.lastRecording = mockRecording + + return OrbLiveListenView( + heartbeatSoundManager: manager, + showTimeline: .constant(true) ) + .environmentObject(ThemeManager()) .modelContainer(for: SavedHeartbeat.self, inMemory: true) } diff --git a/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift new file mode 100644 index 0000000..e067f50 --- /dev/null +++ b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift @@ -0,0 +1,369 @@ +// +// SavedRecordingPlaybackView.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import SwiftUI +import SwiftData + +struct SavedRecordingPlaybackView: View { + let recording: Recording + @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager + @Binding var showTimeline: Bool + + @StateObject private var viewModel = SavedRecordingPlaybackViewModel() + @StateObject var themeManager = ThemeManager() + @FocusState private var isNameFieldFocused: Bool + @Environment(\.modelContext) private var modelContext + + @State private var showDeleteSuccessAlert = false + + var body: some View { + GeometryReader { geometry in + ZStack { + backgroundView + + topControlsView(geometry: geometry) + .opacity(showDeleteSuccessAlert ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: showDeleteSuccessAlert) + + orbView(geometry: geometry) + + nameAndDateView + .zIndex(10) + .opacity(showDeleteSuccessAlert ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: showDeleteSuccessAlert) + + statusTextView + .opacity(showDeleteSuccessAlert ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: showDeleteSuccessAlert) + + deleteButton(geometry: geometry) + + if viewModel.showSuccessAlert { + successAlertView + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(100) + } + + // Delete Success Alert with dark overlay + if showDeleteSuccessAlert { + ZStack { + // Dark overlay + Color.black.opacity(0.6) + .ignoresSafeArea() + + // Alert on top + VStack { + HStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + Text("Deleted.") + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text("Your recording is deleted.") + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer() + } + .padding(20) + .glassEffect(.clear) + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + } + } + .transition(.opacity) + .zIndex(300) + } + } + .ignoresSafeArea() + .sheet(isPresented: $viewModel.showShareSheet) { + ShareSheet(activityItems: [recording.fileURL]) + } + } + .onAppear { + viewModel.setupPlayback( + for: recording, + manager: heartbeatSoundManager, + modelContext: modelContext, + onRecordingUpdated: { + heartbeatSoundManager.loadFromSwiftData() + } + ) + } + .onDisappear { + viewModel.cleanup() + } + } + + private var backgroundView: some View { + ZStack { + Image(themeManager.selectedBackground.imageName) + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + + if viewModel.isPlaying { + BokehEffectView(amplitude: .constant(0.8)) + .opacity(0.5) + } + } + } + + private func topControlsView(geometry: GeometryProxy) -> some View { + VStack { + HStack { + Button { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showTimeline = true + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .frame(width: 50, height: 50) + } + .glassEffect(.clear) + + Spacer() + + if viewModel.isEditingName { + Button { + viewModel.saveName() + isNameFieldFocused = false + } label: { + Text("Save") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background( + Capsule() + .fill(Color(hex: "6B5B95")) + ) + } + .transition(.opacity.animation(.easeInOut)) + } else { + // Normal buttons + HStack { + Button { + viewModel.toggleHaptics() + } label: { + Image(systemName: "iphone.gen3.radiowaves.left.and.right") + .font(.body) + .foregroundColor(viewModel.isHapticsEnabled ? .white : .white.opacity(0.4)) + .frame(width: 48, height: 48) + } + .glassEffect(.clear) + + Button { + viewModel.showShareSheet = true + } label: { + Image(systemName: "square.and.arrow.up") + .font(.body) + .foregroundColor(.white) + .frame(width: 48, height: 48) + } + .glassEffect(.clear) + } + .transition(.opacity.animation(.easeInOut)) + } + } + .padding(.horizontal, 20) + .padding(.top, geometry.safeAreaInsets.top + 20) + + Spacer() + } + } + + private func orbView(geometry: GeometryProxy) -> some View { + ZStack { + AnimatedOrbView() + + if viewModel.isPlaying { + BokehEffectView(amplitude: .constant(0.8)) + .frame(width: 18, height: 18) + } + } + .frame(width: 200, height: 200) + .scaleEffect(viewModel.isPlaying ? 1.3 : 0.8) + .opacity(viewModel.isPlaying ? 1.0 : 0.4) + .animation(.easeInOut(duration: 0.5), value: viewModel.isPlaying) + .scaleEffect(viewModel.orbDragScale) + .offset(y: viewModel.dragOffset) + .gesture( + DragGesture() + .onChanged { value in + viewModel.handleDragChange(value: value, geometry: geometry) + } + .onEnded { value in + viewModel.handleDragEnd(value: value, geometry: geometry) { + // Show alert first + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showDeleteSuccessAlert = true + } + // Then delete after alert is visible + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + viewModel.cleanup() + heartbeatSoundManager.deleteRecording(recording) + // Navigate after another delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showDeleteSuccessAlert = false + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + showTimeline = true + } + } + } + } + } + ) + .onTapGesture { + viewModel.togglePlayback(manager: heartbeatSoundManager, recording: recording) + } + } + + private var statusTextView: some View { + VStack { + Spacer() + VStack(spacing: 8) { + Text(viewModel.isPlaying ? "Playing" : "Tap to play") + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.7)) + + if viewModel.duration > 0 && !viewModel.isDraggingToDelete { + Text("\(Int(viewModel.currentTime))s / \(Int(viewModel.duration))s") + .font(.caption) + .foregroundColor(.white.opacity(0.7)) + } + + if !viewModel.isDraggingToDelete { + Text("Drag up to delete") + .font(.caption) + .foregroundColor(.white.opacity(0.5)) + } + } + .padding(.bottom, 60) + } + .opacity(viewModel.isDraggingToDelete ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + } + + private func deleteButton(geometry: GeometryProxy) -> some View { + Image(systemName: "trash.fill") + .font(.system(size: 28)) + .foregroundColor(.red) + .frame(width: 77, height: 77) + .clipShape(Circle()) + .glassEffect(.clear) + .scaleEffect(viewModel.deleteButtonScale) + .position(x: geometry.size.width / 2, y: 100) + .opacity(viewModel.isDraggingToDelete ? min(abs(viewModel.dragOffset) / 150.0, 1.0) : 0.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + .animation(.easeOut(duration: 0.2), value: viewModel.dragOffset) + } + + private var nameAndDateView: some View { + VStack { + Spacer() + .frame(height: 150) + + VStack(spacing: 8) { + // Editable name + TextField("Recording Name", text: $viewModel.editedName) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .padding(.vertical, 12) + .focused($isNameFieldFocused) + .submitLabel(.done) + .background( + viewModel.isEditingName ? + RoundedRectangle(cornerRadius: 20) + .fill(Color.white.opacity(0.15)) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + : nil + ) + .onChange(of: isNameFieldFocused) { _, isFocused in + if isFocused { + viewModel.startEditing() + } else { + if viewModel.isEditingName { + } + } + } + .onSubmit { + viewModel.saveName() + isNameFieldFocused = false + } + + Text(viewModel.formattedDate) + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + } + + Spacer() + } + } + + private var successAlertView: some View { + VStack { + HStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + Text("Changes saved!") + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text("Your changes is saved.") + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer() + } + .padding(20) + .glassEffect(.clear) + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + } + } +} + +#Preview { + let manager = HeartbeatSoundManager() + let mockRecording = Recording( + fileURL: URL(fileURLWithPath: "/tmp/test.caf"), + createdAt: Date() + ) + + return SavedRecordingPlaybackView( + recording: mockRecording, + heartbeatSoundManager: manager, + showTimeline: .constant(false) + ) + .environmentObject(ThemeManager()) +} diff --git a/Tiny/Features/Onboarding/Models/onboardingModel.swift b/Tiny/Features/Onboarding/Models/OnboardingModel.swift similarity index 100% rename from Tiny/Features/Onboarding/Models/onboardingModel.swift rename to Tiny/Features/Onboarding/Models/OnboardingModel.swift diff --git a/Tiny/Features/Onboarding/ViewModels/onboardingViewModels.swift b/Tiny/Features/Onboarding/ViewModels/OnboardingViewModel.swift similarity index 100% rename from Tiny/Features/Onboarding/ViewModels/onboardingViewModels.swift rename to Tiny/Features/Onboarding/ViewModels/OnboardingViewModel.swift diff --git a/Tiny/Features/Onboarding/Views/onboardingView.swift b/Tiny/Features/Onboarding/Views/OnboardingView.swift similarity index 96% rename from Tiny/Features/Onboarding/Views/onboardingView.swift rename to Tiny/Features/Onboarding/Views/OnboardingView.swift index 1d1c4d0..0fd9b33 100644 --- a/Tiny/Features/Onboarding/Views/onboardingView.swift +++ b/Tiny/Features/Onboarding/Views/OnboardingView.swift @@ -103,25 +103,25 @@ struct OnBoardingView: View { // 4. Calculate the required Vertical Offset for the path's position (375): // This value places the heart's center at the path's start Y coordinate (375). - let yOffsetCorrection = 375 - (heartSize / 2) - - Image("yellowHeart") - .resizable() - .frame(width: heartSize, height: heartSize) - .modifier( - FollowEffect( - pct: progress, - path: LinePath().path(in: pathRect), - rotate: false - ) - ) - // 🔥 The key is to shift the view so the initial center of the heart - // is placed at the path's visual start point (which is pathStartXRelative - // horizontally, and 375 vertically in the ZStack). - .offset( - x: xOffsetCorrection, // Uses the actual path start X point - y: yOffsetCorrection // Uses the fixed Y offset (375) - ) +// let yOffsetCorrection = 375 - (heartSize / 2) +// +// Image("yellowHeart") +// .resizable() +// .frame(width: heartSize, height: heartSize) +// .modifier( +// FollowEffect( +// pct: progress, +// path: LinePath().path(in: pathRect), +// rotate: false +// ) +// ) +// // 🔥 The key is to shift the view so the initial center of the heart +// // is placed at the path's visual start point (which is pathStartXRelative +// // horizontally, and 375 vertically in the ZStack). +// .offset( +// x: xOffsetCorrection, // Uses the actual path start X point +// y: yOffsetCorrection // Uses the fixed Y offset (375) +// ) } VStack(spacing: 0) { @@ -238,6 +238,13 @@ private struct OnboardingPage1: View { .fontWeight(.regular) .multilineTextAlignment(.center) .padding(.horizontal, 30) + + Text("Tiny isn’t a medical app") + .font(.caption) + .foregroundStyle(.peryWinkle) + .fontWeight(.regular) + .italic() + .padding(.horizontal, 30) } } } diff --git a/Tiny/Features/Profile/Models/UserProfileManager.swift b/Tiny/Features/Profile/Models/UserProfileManager.swift new file mode 100644 index 0000000..fc7bc76 --- /dev/null +++ b/Tiny/Features/Profile/Models/UserProfileManager.swift @@ -0,0 +1,104 @@ +// +// UserProfileManager.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 27/11/25. +// + +import SwiftUI +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), + let image = UIImage(data: data) else { + profileImage = nil + return + } + 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" + userEmail = "john.doe@example.com" + saveUserData() + } +} diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..fd1ab78 --- /dev/null +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -0,0 +1,63 @@ +// +// ProfileViewModel.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// + +import SwiftUI +internal import Combine + +@MainActor +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 + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .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() + print("User signed in (dummy)") + } + + func signOut() { + manager.signOut() + print("User signed out") + } +} diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift new file mode 100644 index 0000000..83e9b44 --- /dev/null +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -0,0 +1,524 @@ +// +// ProfileView.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// + +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() + @EnvironmentObject var themeManager: ThemeManager + + @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 { + Image(themeManager.selectedBackground.imageName) + .resizable() + .scaledToFill() + .ignoresSafeArea() + + VStack(spacing: 0) { + // HEADER + profileHeader + .padding(.bottom, 30) + + // FEATURE CARDS + featureCards + .padding(.horizontal, 16) + .frame(height: 160) + + // SETTINGS LIST + 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 + + private var profileHeader: some View { + VStack(spacing: 16) { + GeometryReader { geo in + let size = geo.size.width * 0.28 + + VStack(spacing: 16) { + Spacer() + + NavigationLink { + ProfilePhotoDetailView(viewModel: viewModel) + .environmentObject(authService) + } label: { + profileImageView(size: size) + } + .buttonStyle(.plain) + + Text(authService.currentUser?.name ?? "Guest") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.white) + } + .frame(width: geo.size.width) + } + .frame(height: 260) + } + .listRowBackground(Color.clear) + } + + private func profileImageView(size: CGFloat) -> some View { + Group { + if let img = viewModel.profileImage { + Image(uiImage: img) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } + + private var featureCards: some View { + HStack(spacing: 12) { + let padding: CGFloat = 5 + let spacing: CGFloat = 12 + let cardWidth = (UIScreen.main.bounds.width - (padding * 5 + spacing)) / 2 + + featureCardLeft(width: cardWidth) + featureCardRight(width: cardWidth) + } + } + + private var settingsList: some View { + List { + settingsSection + accountSection + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Color.clear) + } + + private var settingsSection: some View { + Section { + NavigationLink(destination: ThemeCustomizationView()) { + Label("Theme", systemImage: "paintpalette.fill") + .foregroundStyle(.white) + } + NavigationLink(destination: TutorialView()) { + Label("Tutorial", systemImage: "book.fill") + .foregroundStyle(.white) + } + Link(destination: URL(string: "https://example.com/privacy")!) { + Label("Privacy Policy", systemImage: "shield.righthalf.filled") + .foregroundStyle(.white) + } + Link(destination: URL(string: "https://example.com/terms")!) { + Label("Terms and Conditions", systemImage: "doc.text") + .foregroundStyle(.white) + } + } + .listRowBackground(Color("rowProfileGrey")) + } + + private var accountSection: some View { + Section { + if viewModel.isSignedIn { + signedInView + } else { + signInView + } + } + .listRowBackground(Color("rowProfileGrey")) + } + + private var signedInView: some View { + Group { + Button(role: .destructive) { + showingSignOutConfirmation = true + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + .foregroundStyle(.red) + } + .confirmationDialog( + "Sign Out", + isPresented: $showingSignOutConfirmation, + titleVisibility: .visible + ) { + Button("Sign Out", role: .destructive) { + viewModel.signOut() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("You'll need to sign in again to sync your data and access personalized features.") + } + } + } + + private var signInView: some View { + VStack(spacing: 12) { + // Dummy Sign In Button styled like Apple's + Button { + viewModel.signIn() + } label: { + HStack { + Image(systemName: "applelogo") + .font(.system(size: 20, weight: .medium)) + Text("Sign in with Apple") + .font(.system(size: 17, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .foregroundStyle(.black) + .background(Color.white) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + .padding(.vertical, 8) + } + + // MARK: - Feature Cards + + private func featureCardLeft(width: CGFloat) -> some View { + VStack(alignment: .leading) { + HStack { + Image(systemName: "heart.fill") + .font(.caption) + .foregroundColor(.white) + Text("Connected Journey") + .font(.caption) + .foregroundColor(.gray) + } + + Spacer() + + Button(action: { + showRoomCode.toggle() + }, label: { + Text("Connect with Your Partner") + .font(.subheadline) + .foregroundColor(.blue) + .fontWeight(.medium) + }) + + Spacer() + } + .frame(width: width, height: width * 0.63, alignment: .topLeading) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(14) + } + + private func featureCardRight(width: CGFloat) -> some View { + // Calculate current pregnancy week dynamically + let currentWeek: Int = { + guard let pregnancyStartDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + // Fallback to user's stored pregnancy week if start date not available + return authService.currentUser?.pregnancyWeeks ?? 0 + } + + let calendar = Calendar.current + let now = Date() + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: now).weekOfYear ?? 0 + return weeksSinceStart + }() + + return VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "calendar") + .font(.caption) + .foregroundColor(.white) + Text("Pregnancy Age") + .font(.caption) + .foregroundColor(.gray) + } + + Spacer() + + VStack(alignment: .center, spacing: 4) { + Text("\(currentWeek)") + .font(.title) + .fontWeight(.bold) + .foregroundColor(Color("mainViolet")) + Text("Weeks") + .font(.subheadline) + .foregroundColor(Color("mainViolet")) + } + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + } + .frame(width: width * 0.65, height: width * 0.63) + .padding() + .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 + } + } + } + +} + +struct ProfilePhotoDetailView: View { + @EnvironmentObject var authService: AuthenticationService + @ObservedObject var viewModel: ProfileViewModel + @State private var showingImagePicker = false + @State private var showingCamera = false + @State private var showingPhotoOptions = false + @State private var tempUserName: String = "" + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() + + VStack(spacing: 30) { + profilePhotoButton + .padding(.top, 80) + + nameEditSection + .padding(.horizontal, 30) + + Spacer() + } + } + .navigationTitle("Edit Profile") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + Task { + try? await authService.updateUserName(name: tempUserName) + dismiss() + } + } + .disabled(tempUserName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .onAppear { + tempUserName = authService.currentUser?.name ?? "" + } + .sheet(isPresented: $showingPhotoOptions) { + BottomPhotoPickerSheet( + showingCamera: $showingCamera, + showingImagePicker: $showingImagePicker + ) + } + .sheet(isPresented: $showingImagePicker) { + ImagePicker(image: Binding( + get: { viewModel.profileImage }, + set: { viewModel.profileImage = $0 } + ), sourceType: .photoLibrary) + } + .fullScreenCover(isPresented: $showingCamera) { + ImagePicker(image: Binding( + get: { viewModel.profileImage }, + set: { viewModel.profileImage = $0 } + ), sourceType: .camera) + } + } + + private var profilePhotoButton: some View { + Button { + showingPhotoOptions = true + } label: { + ZStack(alignment: .bottomTrailing) { + Group { + if let profileImage = viewModel.profileImage { + Image(uiImage: profileImage) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + } + } + .frame(width: 200, height: 200) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 3) + ) + + // Camera badge + Image(systemName: "camera.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(.white, Color("rowProfileGrey")) + .offset(x: -10, y: -10) + } + } + .buttonStyle(.plain) + } + + private var nameEditSection: some View { + VStack(spacing: 16) { + HStack { + Text("Name") + .foregroundColor(.gray) + .font(.subheadline) + Spacer() + } + + TextField("Enter your name", text: $tempUserName) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.words) + .submitLabel(.done) + } + } +} + +struct BottomPhotoPickerSheet: View { + @Binding var showingCamera: Bool + @Binding var showingImagePicker: Bool + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 20) { + Text("Change Profile Photo") + .font(.headline) + .foregroundColor(.primary) + .padding(.bottom, 8) + + PhotoPickerButton( + title: "Take Photo", + icon: "camera", + action: { + dismiss() + showingCamera = true + } + ) + + PhotoPickerButton( + title: "Choose From Library", + icon: "photo.on.rectangle", + action: { + dismiss() + showingImagePicker = true + } + ) + } + .padding() + .presentationDetents([.height(220)]) + .presentationDragIndicator(.visible) + } +} + +private struct PhotoPickerButton: View { + let title: String + let icon: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Image(systemName: icon) + .font(.system(size: 18)) + Text(title) + .font(.system(size: 16, weight: .medium)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(12) + } + .buttonStyle(.plain) + } +} + +struct ThemeDummy: View { + var body: some View { + Text("Dummy Theme View") + .navigationTitle("Theme") + } +} + +struct TutorialDummy: View { + var body: some View { + Text("Dummy Tutorial View") + .navigationTitle("Tutorial") + } +} + +#Preview { + ProfileView() + .environmentObject(AuthenticationService()) // <-- mock + .environmentObject(HeartbeatSyncManager()) + .environmentObject(ThemeManager()) + .preferredColorScheme(.dark) +} diff --git a/Tiny/Features/Room/Views/RoomCodeDisplayView.swift b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift new file mode 100644 index 0000000..09c4b9b --- /dev/null +++ b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift @@ -0,0 +1,170 @@ +// +// RoomCodeDisplayView.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 27/11/25. +// + +import SwiftUI +import UIKit + +struct RoomCodeDisplayView: View { + @EnvironmentObject var authService: AuthenticationService + @EnvironmentObject var themeManager: ThemeManager + @Environment(\.dismiss) var dismiss + @State private var showCopiedMessage = false + + var body: some View { + ZStack { + Color(hex: "030411").ignoresSafeArea() + + // Bottom circular glow + Circle() + .fill( + RadialGradient( + gradient: Gradient(colors: [ + themeManager.selectedBackground.color.opacity(1), + themeManager.selectedBackground.color.opacity(0) + ]), + center: .center, + startRadius: 0, + endRadius: 600 + ) + ) + .frame(width: 200, height: 200) + .blur(radius: 60) + .offset(y: 120) // Near bottom + + VStack(spacing: 10) { + // 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("yellowHeart") + .resizable() + .scaledToFit() + .frame(width: 30) + + // Title + VStack(spacing: 5) { + 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 { + ZStack { + Button(action: copyRoomCode) { + HStack { + Text(roomCode) + .font(.system(size: 18, weight: .heavy, design: .rounded)) + .tracking(3) + .foregroundColor(.white) + Image(systemName: "doc.on.doc") + .foregroundColor(.white) + } + .padding(.vertical, 20) + .padding(.horizontal, 40) + .background( + // Frosted capsule background + Capsule() + .fill(.ultraThinMaterial) // frosted glass effect + .opacity(0.8) + ) + } + + if showCopiedMessage { + Text("Copied!") + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(Capsule().fill(Color.black.opacity(0.6))) + .offset(y: 50) // Position below the button + .transition(.opacity) + } + } + } 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() + } + } + } + + private func copyRoomCode() { + if let roomCode = authService.currentUser?.roomCode { + UIPasteboard.general.string = roomCode + withAnimation { showCopiedMessage = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { showCopiedMessage = false } + } + } + } +} + +#Preview { + RoomCodeDisplayView() + .environmentObject(AuthenticationService()) + .environmentObject(ThemeManager()) +} + +#Preview { + // Create a mock AuthenticationService with a sample user + let authService = AuthenticationService() + authService.currentUser = User( + id: "1", + email: "mother@example.com", + name: "Jane Doe", + role: .mother, + pregnancyWeeks: 20, + roomCode: "ABCD12", // Sample room code + createdAt: Date() + ) + + let themeManager = ThemeManager() + + return RoomCodeDisplayView() + .environmentObject(authService) + .environmentObject(themeManager) + .preferredColorScheme(.dark) // Optional: show dark mode preview +} diff --git a/Tiny/Features/SignUp/Views/WeekInputView.swift b/Tiny/Features/SignUp/Views/WeekInputView.swift new file mode 100644 index 0000000..d71f649 --- /dev/null +++ b/Tiny/Features/SignUp/Views/WeekInputView.swift @@ -0,0 +1,227 @@ +// +// WeekInputView.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 26/11/25. +// + +import SwiftUI + +struct WeekDistancePreference: Equatable { + let week: Int + let distance: CGFloat +} + +struct WeekDistanceKey: PreferenceKey { + static var defaultValue: [WeekDistancePreference] = [] + + static func reduce(value: inout [WeekDistancePreference], nextValue: () -> [WeekDistancePreference]) { + value.append(contentsOf: nextValue()) + } +} + +enum PregnancyStage { + case early // 0–12 + case midEarly // 12–20 + case midLate // 20–28 + case late // 28–HPL + + static func stage(for week: Int) -> PregnancyStage { + switch week { + case 0..<12: return .early + case 12..<20: return .midEarly + case 20..<28: return .midLate + default: return .late + } + } + + var description: String { + switch self { + case .early: + return "Tiny can let you hear the sounds from your belly." + case .midEarly: + return "At this stage, the heartbeat may be harder to capture." + case .midLate: + return "At this stage, Tiny is able to capture the heartbeat." + case .late: + return "At this stage, the heartbeat is usually clearer." + } + } +} + +struct WeekInputView: View { + @State private var selectedWeek: Int = 20 + var onComplete: ((Int) -> Void)? // Callback when user completes + + var body: some View { + ZStack { + backgroundView + VStack(spacing: 148) { + VStack(spacing: 33) { + TitleDescView(selectedWeek: $selectedWeek) + CustomWeekPicker( + selectedWeek: $selectedWeek, + weeks: Array(1...42), + height: 254 + ) + } + + Button { + // Save selected week to UserDefaults + UserDefaults.standard.set(selectedWeek, forKey: "pregnancyWeek") + print("💾 Saved pregnancy week: \(selectedWeek)") + + // Call completion handler + onComplete?(selectedWeek) + } label: { + Text("Let's begin") + .font(.body) + .foregroundStyle(.white) + .frame(maxWidth: 152) + .padding(.vertical, 13) + .background( + Color(hex: "393953") + ) + .clipShape(Capsule()) + } + .glassEffect(.clear.interactive()) + } + } + } +} + +struct TitleDescView: View { + @Binding var selectedWeek: Int + + var body: some View { + VStack(spacing: 0) { + Text("How far along are you?") + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(Color(hex: "E6E6E6")) + + Text(PregnancyStage.stage(for: selectedWeek).description) + .padding() + .font(.caption) + .foregroundStyle(Color(hex: "D1CCFF")) + } + } +} + +private var backgroundView: some View { + ZStack { + Color.black.ignoresSafeArea() + Image("backgroundPurple") + .resizable() + .ignoresSafeArea() + } +} + +struct CustomWeekPicker: View { + @Binding var selectedWeek: Int + let weeks: [Int] + let height: CGFloat + + private let itemHeight: CGFloat = 40 + + init(selectedWeek: Binding, weeks: [Int] = Array(14...20), height: CGFloat = 250) { + self._selectedWeek = selectedWeek + self.weeks = weeks + self.height = height + } + + var body: some View { + ScrollViewReader { proxy in + GeometryReader { geometry in + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 0) { + ForEach(weeks, id: \.self) { week in + GeometryReader { itemGeometry in + WeekRow( + week: week, + geometry: geometry, + itemGeometry: itemGeometry + ) + } + .frame(height: itemHeight) + .id(week) + } + } + .padding(.vertical, geometry.size.height / 2 - itemHeight / 2) + } + .coordinateSpace(name: "scroll") + .onPreferenceChange(WeekDistanceKey.self) { preferences in + if let closest = preferences.min(by: { $0.distance < $1.distance }) { + if closest.week != selectedWeek { + DispatchQueue.main.async { + self.selectedWeek = closest.week + } + } + } + } + .onAppear { + proxy.scrollTo(selectedWeek, anchor: .center) + } + .simultaneousGesture( + DragGesture() + .onEnded { _ in + snapToNearestWeek(geometry: geometry, proxy: proxy) + } + ) + } + } + .frame(height: height) + } + + private func snapToNearestWeek(geometry: GeometryProxy, proxy: ScrollViewProxy) { + withAnimation(.spring(response: 0.8, dampingFraction: 0.8)) { + proxy.scrollTo(selectedWeek, anchor: .center) + } + } +} + +struct WeekRow: View { + let week: Int + let geometry: GeometryProxy + let itemGeometry: GeometryProxy + + private var distanceFromCenter: CGFloat { + let itemCenter = itemGeometry.frame(in: .named("scroll")).midY + let screenCenter = geometry.size.height / 2 + return abs(itemCenter - screenCenter) + } + + private var scale: CGFloat { + let maxDistance: CGFloat = 100 + let normalizedDistance = min(distanceFromCenter / maxDistance, 1.0) + return 1.0 - (normalizedDistance * 0.4) + } + + private var opacity: CGFloat { + let maxDistance: CGFloat = 120 + let normalizedDistance = min(distanceFromCenter / maxDistance, 1.0) + return 1.0 - (normalizedDistance * 0.7) + } + + private var textColor: Color { + distanceFromCenter < 25 ? Color(hex: "9595E8") : .white + } + + var body: some View { + Text("\(week) \(week == 1 ? "week" : "weeks")") + .font(.system(size: 31, weight: .regular)) + .foregroundColor(textColor) + .scaleEffect(scale) + .opacity(opacity) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .preference( + key: WeekDistanceKey.self, + value: [WeekDistancePreference(week: week, distance: distanceFromCenter)] + ) + } +} + +#Preview { + WeekInputView() +} diff --git a/Tiny/Features/Timeline/Models/TimelineModel.swift b/Tiny/Features/Timeline/Models/TimelineModel.swift index bcfb347..dfd5cf1 100644 --- a/Tiny/Features/Timeline/Models/TimelineModel.swift +++ b/Tiny/Features/Timeline/Models/TimelineModel.swift @@ -10,10 +10,41 @@ import Foundation import SwiftUI // MARK: - Data Model +enum WeekType { + case recorded // Week with actual recordings + case placeholder // Future week placeholder +} + +enum TimelineItem: Identifiable, Equatable { + case recording(Recording) + case moment(Moment) + + var id: UUID { + switch self { + case .recording(let recording): return recording.id + case .moment(let moment): return moment.id + } + } + + var createdAt: Date { + switch self { + case .recording(let recording): return recording.createdAt + case .moment(let moment): return moment.createdAt + } + } +} + struct WeekSection: Identifiable, Equatable { let id = UUID() let weekNumber: Int let recordings: [Recording] + let type: WeekType + + init(weekNumber: Int, recordings: [Recording], type: WeekType = .recorded) { + self.weekNumber = weekNumber + self.recordings = recordings + self.type = type + } } // MARK: - Shared Helper @@ -46,3 +77,42 @@ struct ContinuousWave: Shape { return path } } + +// MARK: - Wave with Gaps for Orbs +struct SegmentedWave: Shape { + var totalHeight: CGFloat + var period: CGFloat + var amplitude: CGFloat + var gapPositions: [CGFloat] // Y positions where gaps should be + var gapSize: CGFloat = 30 // Size of gap around each position + + func path(in rect: CGRect) -> Path { + var path = Path() + let centerX = rect.width / 2 + var isDrawing = false + + // Draw sine wave with gaps at orb positions + for yCoord in stride(from: 0, through: totalHeight, by: 5) { + // Check if we're in a gap + let inGap = gapPositions.contains { abs(yCoord - $0) < gapSize } + + let angle = (yCoord / period) * .pi * 2 + let xCoord = centerX + sin(angle) * amplitude + let point = CGPoint(x: xCoord, y: yCoord) + + if inGap { + // We're in a gap, stop drawing + isDrawing = false + } else { + // We're not in a gap, continue or start drawing + if !isDrawing { + path.move(to: point) + isDrawing = true + } else { + path.addLine(to: point) + } + } + } + return path + } +} diff --git a/Tiny/Features/Timeline/Views/DeleteAlert.swift b/Tiny/Features/Timeline/Views/DeleteAlert.swift new file mode 100644 index 0000000..393c50a --- /dev/null +++ b/Tiny/Features/Timeline/Views/DeleteAlert.swift @@ -0,0 +1,82 @@ +// +// delete alert.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import SwiftUI + +struct DeleteAlert: View { + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + VStack(alignment: .leading, spacing: 10) { + // Title + Text("Delete this moment?") + .font(.body) + .fontWeight(.bold) + .foregroundColor(.white) + + // Message + Text("This action is permanent and can't be undone.") + .font(.callout) + .foregroundColor(.white) + } + .padding(8) + .padding(.bottom, 24) + + // Buttons (side by side) + HStack(spacing: 12) { + // Delete Button (left) + Button { + } label: { + Text("Delete") + .font(.headline) + .foregroundColor(.red) + .frame(maxWidth: .infinity) + .frame(height: 48) + .cornerRadius(25) + } + .glassEffect(.regular.tint(.black.opacity(0.20))) + + // Keep Button (right, with gradient) + Button { + } label: { + Text("Keep") + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background( + RadialGradient( + colors: [ + Color(hex: "8376DB"), + Color(hex: "705AB1") + ], + center: .center, + startRadius: 5, + endRadius: 100 + ) + ) + .cornerRadius(25) + } + } + } + .frame(width: 300) + .padding(14) + .cornerRadius(20) + .glassEffect(.regular.tint(.black.opacity(0.50)), in: .rect(cornerRadius: 20.0)) + + } +} + +#Preview { + ZStack { + Image("librarySample1") + .resizable() + .frame(maxWidth: .infinity) + DeleteAlert() + } +} diff --git a/Tiny/Features/Timeline/Views/MainTimelineListView.swift b/Tiny/Features/Timeline/Views/MainTimelineListView.swift index 8a4400e..21efd71 100644 --- a/Tiny/Features/Timeline/Views/MainTimelineListView.swift +++ b/Tiny/Features/Timeline/Views/MainTimelineListView.swift @@ -12,11 +12,15 @@ struct MainTimelineListView: View { @Binding var selectedWeek: WeekSection? var animation: Namespace.ID + // Animation support + @ObservedObject var animationController: TimelineAnimationController + var isFirstTimeVisit: Bool = false + // Configuration private let itemSpacing: CGFloat = 160 private let wavePeriod: CGFloat = 600 - private let topPadding: CGFloat = 150 - private let bottomPadding: CGFloat = 200 + private let topPadding: CGFloat = 80 + private let bottomPadding: CGFloat = 100 var body: some View { GeometryReader { geometry in @@ -26,77 +30,158 @@ struct MainTimelineListView: View { topPadding + (CGFloat(totalItems) * itemSpacing) + bottomPadding ) - ScrollView(showsIndicators: false) { - // Reader to scroll to bottom if needed (optional) - ScrollViewReader { proxy in - ZStack(alignment: .top) { - // 1. Wavy Line - ContinuousWave( - totalHeight: contentHeight, - period: wavePeriod, - amplitude: geometry.size.width * 0.35 - ) - .stroke( - LinearGradient( - stops: [ - .init(color: .clear, location: 0.0), - .init(color: .white.opacity(0.2), location: 0.1), - .init(color: .white.opacity(0.3), location: 1.0) - ], - startPoint: .top, - endPoint: .bottom - ), - style: StrokeStyle(lineWidth: 2, lineCap: .round) - ) - .frame(width: geometry.size.width, height: contentHeight) - - // 2. Week Orbs - // Iterate directly: Index 0 (Earliest Week) -> Top - ForEach(Array(groupedData.enumerated()), id: \.element.id) { index, week in - - // Simple linear progression: 0 is Top, Max is Bottom - let yPos = topPadding + (CGFloat(index) * itemSpacing) - - let xPos = TimelineLayout.calculateX( - yCoor: yPos, - width: geometry.size.width, + ZStack { + ScrollView(showsIndicators: false) { + ScrollViewReader { proxy in + ZStack(alignment: .top) { + let orbPositions = groupedData.indices.map { index in + topPadding + CGFloat(index) * itemSpacing + } + + SegmentedWave( + totalHeight: contentHeight, period: wavePeriod, - amplitude: geometry.size.width * 0.35 + amplitude: geometry.size.width * 0.35, + gapPositions: orbPositions, + gapSize: 20 + ) + .trim(from: 1.0 - animationController.pathProgress, to: 1.0) + .stroke( + Color.white.opacity(0.3), + style: StrokeStyle(lineWidth: 2, lineCap: .round) ) + .frame(width: geometry.size.width, height: contentHeight) - VStack(spacing: 8) { - // The Orb - ZStack { - AnimatedOrbView(size: 20) - .shadow(color: .orange.opacity(0.4), radius: 15) - } - .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) - .onTapGesture { - withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { - selectedWeek = week + ForEach(Array(groupedData.enumerated()), id: \.element.id) { index, week in + + let yPos = topPadding + (CGFloat(index) * itemSpacing) + + let xPos = TimelineLayout.calculateX( + yCoor: yPos, + width: geometry.size.width, + period: wavePeriod, + amplitude: geometry.size.width * 0.35 + ) + + VStack(spacing: 8) { + if week.type == .placeholder { + let lastIndex = groupedData.count - 1 + + if isFirstTimeVisit { + // First time visit: animate dots and orb transformation + if index == lastIndex && animationController.orbVisible { + // Show orb for the current week (last/bottom item) + ZStack { + AnimatedOrbView(size: 20) + .shadow(color: .orange.opacity(0.4), radius: 15) + } + .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) + .onTapGesture { + withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { + selectedWeek = week + } + } + } else { + // Show dots for future weeks during animation + let reversedIndex = lastIndex - index + if reversedIndex < animationController.dotsVisible.count && + animationController.dotsVisible[reversedIndex] { + PlaceholderDot() + } + } + } else { + // Non-first visit: show orb for current week, dots for future weeks + if index == lastIndex { + // Current week gets an orb + ZStack { + AnimatedOrbView(size: 20) + .shadow(color: .orange.opacity(0.4), radius: 15) + } + .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) + .onTapGesture { + withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { + selectedWeek = week + } + } + } else { + // Future weeks get dots + PlaceholderDot() + } + } + } else { + let shouldShowOrb = !isFirstTimeVisit || (isFirstTimeVisit && animationController.orbVisible) + + if shouldShowOrb { + ZStack { + AnimatedOrbView(size: 20) + .shadow(color: .orange.opacity(0.4), radius: 15) + } + .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) + .onTapGesture { + withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { + selectedWeek = week + } + } + } + } + + let shouldShowLabel: Bool = { + if week.type == .recorded { + return true + } else if week.type == .placeholder { + let lastIndex = groupedData.count - 1 + // Show label if it's the current week (last/bottom item) + if isFirstTimeVisit { + return index == lastIndex && animationController.orbVisible + } else { + return index == lastIndex + } + } + return false + }() + + if shouldShowLabel { + Text("Week \(week.weekNumber)") + .font(.headline) + .foregroundColor(.white) + .padding(6) + .matchedGeometryEffect(id: "label_\(week.weekNumber)", in: animation) } } - - // Label - Text("Week \(week.weekNumber)") - .font(.headline) - .foregroundColor(.white) - .padding(6) - .matchedGeometryEffect(id: "label_\(week.weekNumber)", in: animation) + .frame(width: 120, height: 120) + .position(x: xPos, y: yPos) + .id(week.id) } - .frame(width: 120, height: 120) - .position(x: xPos, y: yPos) - .id(week.id) // Useful for auto-scrolling } - } - .frame(width: geometry.size.width, height: contentHeight) - .onAppear { - // Optional: Auto-scroll to the latest week (bottom) - if let last = groupedData.last { - proxy.scrollTo(last.id, anchor: .center) + .frame(width: geometry.size.width, height: contentHeight) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + if let last = groupedData.last { + withAnimation(.easeOut(duration: 0.5)) { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } } } } + + LinearGradient( + stops: [ + .init(color: .clear, location: 0.0), + .init(color: .black.opacity(0.2), location: 0.2), + .init(color: .black.opacity(0.5), location: 0.4), + .init(color: .black.opacity(0.8), location: 0.6), + .init(color: .black.opacity(0.95), location: 0.8), + .init(color: .black, location: 1.0) + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: geometry.size.height * 0.3) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .allowsHitTesting(false) + .ignoresSafeArea() } } } diff --git a/Tiny/Features/Timeline/Views/PlaceholderDot.swift b/Tiny/Features/Timeline/Views/PlaceholderDot.swift new file mode 100644 index 0000000..0d187ef --- /dev/null +++ b/Tiny/Features/Timeline/Views/PlaceholderDot.swift @@ -0,0 +1,43 @@ +// +// PlaceholderDot.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 28/11/25. +// + +import SwiftUI + +struct PlaceholderDot: View { + @State private var isPulsing = false + + var body: some View { + ZStack { + // Outer glow + Circle() + .fill(Color.white.opacity(0.2)) + .frame(width: 16, height: 16) + .blur(radius: 4) + .scaleEffect(isPulsing ? 1.2 : 1.0) + + // Inner dot + Circle() + .fill(Color.white.opacity(0.8)) + .frame(width: 8, height: 8) + } + .onAppear { + withAnimation( + .easeInOut(duration: 1.5) + .repeatForever(autoreverses: true) + ) { + isPulsing = true + } + } + } +} + +#Preview { + ZStack { + Color.black.ignoresSafeArea() + PlaceholderDot() + } +} diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 3c8ca1f..075eda7 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -9,114 +9,303 @@ import SwiftUI struct PregnancyTimelineView: View { @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager - // ⬇️ NEW: Binding to control close @Binding var showTimeline: Bool let onSelectRecording: (Recording) -> Void + let onDisableSwipe: (Bool) -> Void // Callback to disable TabView swipe + let isMother: Bool // Add this parameter + var inputWeek: Int? // Week from onboarding input @Namespace private var animation @State private var selectedWeek: WeekSection? @State private var groupedData: [WeekSection] = [] + @State private var isProfilePresented = false + @EnvironmentObject var themeManager: ThemeManager + + // Animation support + @StateObject private var animationController = TimelineAnimationController() + @State private var isFirstTimeVisit: Bool = false + + @ObservedObject private var userProfile = UserProfileManager.shared var body: some View { - ZStack { - LinearGradient(colors: [Color(red: 0.05, green: 0.05, blue: 0.15), Color.black], startPoint: .top, endPoint: .bottom) - .ignoresSafeArea() - + NavigationStack { ZStack { + Image(themeManager.selectedBackground.imageName) + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + if let week = selectedWeek { - TimelineDetailView(week: week, animation: animation, onSelectRecording: onSelectRecording) - .transition(.opacity) + TimelineDetailView( + week: week, + animation: animation, + onSelectRecording: onSelectRecording, + heartbeatSoundManager: heartbeatSoundManager, + isMother: isMother + ) + .transition(.opacity) } else { - MainTimelineListView(groupedData: groupedData, selectedWeek: $selectedWeek, animation: animation) - .transition(.opacity) + MainTimelineListView( + groupedData: groupedData, + selectedWeek: $selectedWeek, + animation: animation, + animationController: animationController, + isFirstTimeVisit: isFirstTimeVisit, +// isMother: isMother + ) + .transition(.opacity) + } + + // Top navigation bar + GeometryReader { geometry in + VStack { + HStack { + if selectedWeek != nil { + Button { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + selectedWeek = nil + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .frame(width: 50, height: 50) + } + .glassEffect(.clear) + .matchedGeometryEffect(id: "navButton", in: animation) + } else { + Spacer() + } + + Spacer() + + if selectedWeek == nil { + Button { + isProfilePresented = true + } label: { + Group { + if let image = userProfile.profileImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.white.opacity(0.8)) + } + } + .frame(width: 50, height: 50) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + } + .opacity(isFirstTimeVisit ? (animationController.profileVisible ? 1.0 : 0.0) : 1.0) + } + } + .padding(.horizontal, 20) + .padding(.top, geometry.safeAreaInsets.top + 39) + + Spacer() + } } } - - navigationButtons + .navigationDestination(isPresented: $isProfilePresented) { + ProfileView() + } + .onAppear(perform: groupRecordings) + } + .onAppear { + print("Timeline appeared") + initializeTimeline() + } + .onChange(of: heartbeatSoundManager.savedRecordings) { oldValue, newValue in + print("Recordings changed: \(oldValue.count) -> \(newValue.count)") + groupRecordings() + } + .onChange(of: isProfilePresented) { _, newValue in + // Disable TabView swipe when ProfileView is presented + print("🔄 Profile presented changed: \(newValue)") + onDisableSwipe(newValue) } - .onAppear(perform: groupRecordings) } - private var navigationButtons: some View { - VStack { - if selectedWeek != nil { - // Back Button (Detail -> List) - HStack { - Button { - withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { selectedWeek = nil } - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .bold)) - .foregroundColor(.white) - .frame(width: 50, height: 50) - .clipShape(Circle()) - } - .glassEffect(.clear) - .matchedGeometryEffect(id: "navButton", in: animation) - .padding(.leading, 20) - .padding(.top, 0) - Spacer() - } - Spacer() - } else { - // ⬇️ Book Button (List -> Close to Orb) - Spacer() - 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()) - } - .glassEffect(.clear) - .matchedGeometryEffect(id: "navButton", in: animation) - .padding(.bottom, 50) + private func initializeTimeline() { + // Check if this is first time visit + isFirstTimeVisit = !UserDefaults.standard.bool(forKey: "hasSeenTimelineAnimation") + + // Get week from parameter or UserDefaults + let week = inputWeek ?? UserDefaults.standard.integer(forKey: "pregnancyWeek") + + print("🎬 Timeline initialization:") + print(" inputWeek parameter: \(inputWeek ?? -1)") + print(" UserDefaults week: \(UserDefaults.standard.integer(forKey: "pregnancyWeek"))") + print(" Final week: \(week)") + print(" isFirstTimeVisit: \(isFirstTimeVisit)") + + if isFirstTimeVisit, week > 0 { + // First time: Create initial data with placeholder dots + print("🎬 First time visit - creating initial timeline for week \(week)") + + // Create 3 weeks: reversed order (newest at bottom) + groupedData = [ + WeekSection(weekNumber: week + 2, recordings: [], type: .placeholder), + WeekSection(weekNumber: week + 1, recordings: [], type: .placeholder), + WeekSection(weekNumber: week, recordings: [], type: .placeholder) + ] + + // Start animation after a brief delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + print("🎬 Starting timeline animation!") + self.animationController.startAnimation() } + + // Mark as seen + UserDefaults.standard.set(true, forKey: "hasSeenTimelineAnimation") + } else { + // Normal visit: Group recordings and show everything immediately + print("📊 Normal visit - grouping recordings") + + // Set animation controller to complete state so path and orbs are visible + animationController.skipAnimation() + + groupRecordings() } - .ignoresSafeArea(.all, edges: .bottom) } private func groupRecordings() { let raw = heartbeatSoundManager.savedRecordings + print("📊 Grouping \(raw.count) recordings") + + guard let initialPregnancyWeek = inputWeek else { + print("⚠️ No pregnancy week available, showing empty timeline") + groupedData = [] + return + } + let calendar = Calendar.current + let now = Date() + + // Store initial pregnancy week and date in UserDefaults if not already stored + if UserDefaults.standard.object(forKey: "pregnancyStartDate") == nil { + let pregnancyStartDate = calendar.date(byAdding: .weekOfYear, value: -initialPregnancyWeek, to: now)! + UserDefaults.standard.set(pregnancyStartDate, forKey: "pregnancyStartDate") + UserDefaults.standard.set(initialPregnancyWeek, forKey: "initialPregnancyWeek") + print("💾 Stored pregnancy start date: \(pregnancyStartDate)") + } + // Get the stored pregnancy start date + guard let storedDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + print("⚠️ Could not get pregnancy start date") + groupedData = [] + return + } + // Normalize to start of day to avoid time-based drift + let pregnancyStartDate = calendar.startOfDay(for: storedDate) + + // Calculate CURRENT pregnancy week based on time elapsed since pregnancy start + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: now).weekOfYear ?? 0 + let currentPregnancyWeek = weeksSinceStart + + print("📅 Pregnancy started: \(pregnancyStartDate)") + print("📅 Initial pregnancy week: \(initialPregnancyWeek)") + print("📅 Current pregnancy week (calculated): \(currentPregnancyWeek)") + print("📅 Weeks elapsed: \(currentPregnancyWeek - initialPregnancyWeek)") + + // Group recordings by pregnancy week let grouped = Dictionary(grouping: raw) { recording -> Int in - return calendar.component(.weekOfYear, from: recording.createdAt) + // Calculate how many weeks since pregnancy started + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: recording.createdAt).weekOfYear ?? 0 + let pregnancyWeek = weeksSinceStart + print(" Recording from \(recording.createdAt) -> Pregnancy week \(pregnancyWeek)") + return pregnancyWeek + } + + var recordedWeeks = grouped.map { + WeekSection(weekNumber: $0.key, recordings: $0.value.sorted(by: { $0.createdAt > $1.createdAt }), type: .recorded) + }.sorted(by: { $0.weekNumber > $1.weekNumber }) // Reversed: newest (highest week) at bottom + + // Show current week + next 2 weeks (as placeholders if no recordings) + let weeksToShow = [currentPregnancyWeek, currentPregnancyWeek + 1, currentPregnancyWeek + 2] + + for week in weeksToShow where !recordedWeeks.contains(where: { $0.weekNumber == week }) { + recordedWeeks.append( + WeekSection(weekNumber: week, recordings: [], type: .placeholder) + ) } + + // Sort again after adding placeholders + recordedWeeks.sort(by: { $0.weekNumber > $1.weekNumber }) - self.groupedData = grouped.map { - WeekSection(weekNumber: $0.key, recordings: $0.value.sorted(by: { $0.createdAt > $1.createdAt })) - }.sorted(by: { $0.weekNumber < $1.weekNumber }) + self.groupedData = recordedWeeks + + print("📊 Created \(groupedData.count) week sections") + for section in groupedData { + print(" Week \(section.weekNumber): \(section.recordings.count) recordings (\(section.type))") + } } } #Preview { + @Previewable @State var showTimeline = true + let mockManager = HeartbeatSoundManager() - + let themeManager = ThemeManager() + let calendar = Calendar.current 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)) + + // Sample mock data per week + let mockData: [(weeksAgo: Int, count: Int)] = [ + (9, 2), // Week 1 + (8, 3), // Week 2 + (7, 1), // Week 3 + (6, 4), // Week 4 + (5, 2), // Week 5 + (4, 3), // Week 6 + (3, 2), // Week 7 + (2, 3), // Week 8 + (1, 1) // Week 9 ] - + + // Generate weeks 1–9 + for (weeksAgo, count) in mockData { + if let weekDate = calendar.date(byAdding: .day, value: -(weeksAgo * 7), to: now) { + for index in 0.. Void - var body: some View { - GeometryReader { geometry in - VStack(spacing: 0) { - // 1. Header Area (Title + Hero Orb) - headerView - - // 2. The List of Recordings (Glowing Dots) - recordingsScrollView(geometry: geometry) - } + @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager + + let isMother: Bool + + @State private var showMenu = false + @State private var showImagePicker = false + @State private var sourceType: UIImagePickerController.SourceType = .photoLibrary + @State private var selectedImage: UIImage? + @State private var selectedMoment: Moment? + @State private var showSuccessAlert = false + @State private var successMessage = (title: "", subtitle: "") + + private var currentItems: [TimelineItem] { + let calendar = Calendar.current + guard let storedDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + return [] + } + let pregnancyStartDate = calendar.startOfDay(for: storedDate) + + // Filter recordings + let recordings = heartbeatSoundManager.savedRecordings.compactMap { recording -> TimelineItem? in + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: recording.createdAt).weekOfYear ?? 0 + return weeksSinceStart == week.weekNumber ? .recording(recording) : nil + } + + // Filter moments + let moments = heartbeatSoundManager.savedMoments.compactMap { moment -> TimelineItem? in + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: moment.createdAt).weekOfYear ?? 0 + return weeksSinceStart == week.weekNumber ? .moment(moment) : nil } + + // Combine and sort (Oldest first) + return (recordings + moments).sorted { $0.createdAt < $1.createdAt } } - private var headerView: some View { - VStack(spacing: 50) { + var body: some View { + GeometryReader { geometry in ZStack { - // Title - Text("Week \(week.weekNumber)") - .font(.system(size: 28, weight: .bold)) - .foregroundColor(.white) - .matchedGeometryEffect(id: "label_\(week.weekNumber)", in: animation) - .padding(.top, 10) + // Scrollable content with orb and recordings + recordingsScrollView(geometry: geometry) + + // Fixed header with back button and week title + VStack(spacing: 0) { + HStack { + // Back button placeholder (actual button is in PregnancyTimelineView) + Color.clear + .frame(width: 50, height: 50) + + Spacer() + + // Week title + Text("Week \(week.weekNumber)") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.white) + + Spacer() + + // Right side spacer for balance + // Right side spacer or Add Button + // Right side spacer or Add Button + VStack(alignment: .trailing, spacing: 0) { + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + showMenu.toggle() + } + } label: { + Image(systemName: "plus") + .font(.system(size: 20, weight: .medium)) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background(.ultraThinMaterial) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + } + .rotationEffect(.degrees(showMenu ? 45 : 0)) + + // Custom Popover Menu + if showMenu { + VStack(spacing: 0) { + Button { + sourceType = .photoLibrary + showImagePicker = true + showMenu = false + } label: { + HStack { + Image(systemName: "photo.on.rectangle") + Text("Photo Library") + Spacer() + } + .padding() + .foregroundColor(.white) + } + + Divider() + .background(Color.white.opacity(0.2)) + + Button { + sourceType = .camera + showImagePicker = true + showMenu = false + } label: { + HStack { + Image(systemName: "camera") + Text("Take Photo") + Spacer() + } + .padding() + .foregroundColor(.white) + } + } + .frame(width: 200) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) + .padding(.top, 8) + .transition(.scale(scale: 0.8, anchor: .topTrailing).combined(with: .opacity)) + } + } + .zIndex(100) // Ensure menu is on top + } + .padding(.horizontal, 20) + .padding(.top, geometry.safeAreaInsets.top + 40) + .background( + LinearGradient( + colors: [Color.black.opacity(0.6), Color.clear], + startPoint: .top, + endPoint: .bottom + ) + ) + + Spacer() + } + + // Moment Overlay + if let moment = selectedMoment { + MomentOverlayView( + moment: moment, + onDismiss: { selectedMoment = nil }, + onDelete: { + // Show alert first + successMessage = (title: "Deleted.", subtitle: "Your moment is deleted.") + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = true + } + // Then delete after alert is visible + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + heartbeatSoundManager.deleteMoment(moment) + selectedMoment = nil + // Hide alert after another delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = false + } + } + } + } + ) + .transition(.opacity) + .zIndex(100) + } + + // Success Alert with dark overlay + if showSuccessAlert { + ZStack { + // Dark overlay + Color.black.opacity(0.6) + .ignoresSafeArea() + + // Alert on top + VStack { + HStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + Text(successMessage.title) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text(successMessage.subtitle) + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer() + } + .padding(20) + .glassEffect(.clear) + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + } + } + .transition(.opacity) + .zIndex(300) + } } - .frame(maxWidth: .infinity) -// .frame(height: 60) - - // The "Hero" Orb (Animated from previous screen) - ZStack { - AnimatedOrbView(size: 120) - .shadow(color: .orange.opacity(0.6), radius: 30) + .onChange(of: selectedImage) { _, newImage in + if let image = newImage { + heartbeatSoundManager.saveMoment(image: image) + } + } + + .onTapGesture { + // Close menu when tapping outside + if showMenu { + withAnimation { + showMenu = false + } + } + } + .sheet(isPresented: $showImagePicker) { + ImagePicker(image: $selectedImage, sourceType: sourceType) } - .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) - .frame(height: 140) - .padding(.bottom, 20) } } private func recordingsScrollView(geometry: GeometryProxy) -> some View { - let recordings = week.recordings + let items = currentItems let recSpacing: CGFloat = 100 - let recHeight = max(geometry.size.height - 300, CGFloat(recordings.count) * recSpacing + 200) + let orbHeight: CGFloat = 115 + let topPadding: CGFloat = geometry.safeAreaInsets.top + 100 + + // Calculate total height + let contentHeight = orbHeight + CGFloat(items.count) * recSpacing + 200 return ScrollView(showsIndicators: false) { ZStack(alignment: .top) { - // Tighter Wavy Path for details + + // Continuous Wave Path ContinuousWave( - totalHeight: recHeight, - period: 400, // Faster wave - amplitude: 60 // Smaller width + totalHeight: contentHeight - (orbHeight / 2), + period: 400, + amplitude: 60 ) .stroke( Color.white.opacity(0.15), style: StrokeStyle(lineWidth: 1, lineCap: .round) ) - .frame(width: geometry.size.width, height: recHeight) + .frame(width: geometry.size.width, height: contentHeight - (orbHeight / 2)) + .offset(y: orbHeight / 2) // Start from center of orb (visually bottom due to ZStack alignment) + + // Orb at the top + ZStack { + AnimatedOrbView(size: orbHeight) + .shadow(color: .orange.opacity(0.6), radius: 30) + } + .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) + .frame(height: orbHeight) + .frame(maxWidth: .infinity) // Center horizontally + // No top padding here, it sits at y=0 of the ZStack (start of wave) - // Glowing Dots (Recordings) - ForEach(Array(recordings.enumerated()), id: \.element.id) { index, recording in - let yPos: CGFloat = 40 + (CGFloat(index) * recSpacing) + // Items (Recordings & Moments) + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + let yPos = orbHeight + CGFloat(index) * recSpacing + 40 let xPos = TimelineLayout.calculateX( yCoor: yPos, width: geometry.size.width, @@ -77,45 +285,84 @@ struct TimelineDetailView: View { amplitude: 60 ) - HStack(spacing: 15) { - // Label Left or Right based on X position - if xPos > geometry.size.width / 2 { - recordingLabel(for: recording) - glowingDot - .onTapGesture { onSelectRecording(recording) } - } else { + HStack(spacing: 16) { + switch item { + case .recording(let recording): + // Glowing dot glowingDot .onTapGesture { onSelectRecording(recording) } + + // Label recordingLabel(for: recording) + + case .moment(let moment): + // Moment Thumbnail + momentThumbnail(for: moment) + .onTapGesture { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + selectedMoment = moment + } + } + + // Date Label + Text(moment.createdAt.formatted(date: .long, time: .omitted)) + .font(.system(size: 13)) + .foregroundColor(.white.opacity(0.6)) } + + Spacer() } - .frame(width: 300, height: 60) - .position(x: xPos, y: yPos) + .padding(.leading, xPos - (itemIsMoment(item) ? 25 : 6)) // Adjust padding based on item size + .frame(width: geometry.size.width, alignment: .leading) + .position(x: geometry.size.width / 2, y: yPos) } } - .frame(width: geometry.size.width, height: recHeight) + .frame(width: geometry.size.width, height: contentHeight) + .padding(.top, topPadding) } } - // MARK: - Components + private func itemIsMoment(_ item: TimelineItem) -> Bool { + if case .moment = item { return true } + return false + } + + func momentThumbnail(for moment: Moment) -> some View { + AsyncImage(url: moment.fileURL) { phase in + if let image = phase.image { + image + .resizable() + .scaledToFill() + } else { + Color.gray.opacity(0.3) + } + } + .frame(width: 50, height: 50) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white, lineWidth: 2)) + .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2) + } + var glowingDot: some View { ZStack { - Circle().fill(Color.white).frame(width: 8, height: 8) - Circle().stroke(Color.white.opacity(0.5), lineWidth: 1).frame(width: 16, height: 16) - Circle().fill(Color.white.opacity(0.2)).frame(width: 24, height: 24).blur(radius: 4) + Circle() + .fill(Color.white) + .frame(width: 12, height: 12) + .shadow(color: .white.opacity(0.8), radius: 8, x: 0, y: 0) // Glow effect } } func recordingLabel(for recording: Recording) -> some View { - let dateName = recording.fileURL.deletingPathExtension().lastPathComponent - let text = formatTimestamp(dateName) - - return Text(text) - .font(.caption) - .foregroundColor(.white.opacity(0.8)) - .padding(6) - .background(Color.black.opacity(0.3)) - .cornerRadius(4) + VStack(alignment: .leading, spacing: 4) { + Text(recording.displayName ?? "Baby's Heartbeat") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + + Text(recording.createdAt.formatted(date: .long, time: .omitted)) + .font(.system(size: 13)) + .foregroundColor(.white.opacity(0.6)) + } } private func formatTimestamp(_ raw: String) -> String { @@ -130,48 +377,167 @@ struct TimelineDetailView: View { } } +struct MomentOverlayView: View { + let moment: Moment + let onDismiss: () -> Void + let onDelete: () -> Void + @State private var showDeleteAlert = false + + var body: some View { + ZStack { + // Darkened background + Color.black.opacity(0.8) + .ignoresSafeArea() + .onTapGesture { + onDismiss() + } + + VStack(spacing: 20) { + // Photo with overlay + ZStack(alignment: .bottom) { + AsyncImage(url: moment.fileURL) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity) + .frame(height: 400) + .clipped() + } else { + ProgressView() + .tint(.white) + .frame(maxWidth: .infinity) + .frame(height: 400) + } + } + .frame(maxWidth: .infinity) + .frame(height: 400) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .padding(.horizontal, 20) + + // Date overlay - no background, body font + Text(moment.createdAt.formatted(date: .long, time: .omitted)) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + .padding(.bottom, 20) + } + + // Delete Button + Button { + showDeleteAlert = true + } label: { + Image(systemName: "trash.fill") + .font(.system(size: 24)) + .foregroundColor(.red) + .frame(width: 50, height: 50) + .background(Circle().fill(Color.white.opacity(0.1))) + } + .padding(.top, 10) + } + + // Custom Alert (matching screenshot) + if showDeleteAlert { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { + showDeleteAlert = false + } + + VStack(alignment: .leading, spacing: 10) { + + VStack(alignment: .leading, spacing: 10) { + // Title + Text("Delete this moment?") + .font(.body) + .fontWeight(.bold) + .foregroundColor(.white) + + // Message + Text("This action is permanent and can't be undone.") + .font(.callout) + .foregroundColor(.white) + } + .padding(8) + .padding(.bottom, 24) + + // Buttons (side by side) + HStack(spacing: 12) { + // Delete Button (left) + Button { + showDeleteAlert = false + onDelete() + } label: { + Text("Delete") + .font(.headline) + .foregroundColor(.red) + .frame(maxWidth: .infinity) + .frame(height: 48) + .cornerRadius(25) + } + .glassEffect(.regular.tint(.black.opacity(0.20))) + + // Keep Button (right, with gradient) + Button { + showDeleteAlert = false + } label: { + Text("Keep") + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background( + RadialGradient( + colors: [ + Color(hex: "8376DB"), + Color(hex: "705AB1") + ], + center: .center, + startRadius: 5, + endRadius: 100 + ) + ) + .cornerRadius(25) + } + } + } + .frame(width: 300) + .padding(14) + .cornerRadius(20) + .glassEffect(.regular.tint(.black.opacity(0.50)), in: .rect(cornerRadius: 20.0)) + } + } + } +} + #Preview { struct PreviewWrapper: View { @Namespace var animation - 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 mockWeek: WeekSection { + let dummyURL = URL(fileURLWithPath: "Heartbeat-1715421234.m4a") + + let rec1 = Recording(fileURL: dummyURL, createdAt: Date()) + let rec2 = Recording(fileURL: dummyURL, createdAt: Date().addingTimeInterval(-3600)) + + return WeekSection(weekNumber: 24, recordings: [rec1, rec2, rec1]) + } 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)") - } - ) - } + TimelineDetailView( + week: mockWeek, + animation: animation, + onSelectRecording: { recording in + print("Selected: \(recording.createdAt)") + }, + heartbeatSoundManager: HeartbeatSoundManager(), + isMother: true + ) + .background(Color(red: 0.1, green: 0.1, blue: 0.2)) + .environmentObject(ThemeManager()) } } 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/Info.plist b/Tiny/Info.plist index 9b9d450..0c67376 100644 --- a/Tiny/Info.plist +++ b/Tiny/Info.plist @@ -1,10 +1,5 @@ - - CFBundleIdentifier - - ITSAppUsesNonExemptEncryption - - + 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/backgrounds/Contents.json b/Tiny/Resources/Assets.xcassets/backgrounds/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/backgrounds/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/backgrounds/bgBlack.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/backgrounds/bgBlack.imageset/Contents.json new file mode 100644 index 0000000..cba26f5 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/backgrounds/bgBlack.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bgBlack.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/backgrounds/bgBlack.imageset/bgBlack.png b/Tiny/Resources/Assets.xcassets/backgrounds/bgBlack.imageset/bgBlack.png new file mode 100644 index 0000000..f86b6ab Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/backgrounds/bgBlack.imageset/bgBlack.png differ diff --git a/Tiny/Resources/Assets.xcassets/backgrounds/bgBlue.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/backgrounds/bgBlue.imageset/Contents.json new file mode 100644 index 0000000..1c0f20e --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/backgrounds/bgBlue.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bgBlue.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/backgrounds/bgBlue.imageset/bgBlue.png b/Tiny/Resources/Assets.xcassets/backgrounds/bgBlue.imageset/bgBlue.png new file mode 100644 index 0000000..0f75987 Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/backgrounds/bgBlue.imageset/bgBlue.png differ diff --git a/Tiny/Resources/Assets.xcassets/backgrounds/bgPink.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/backgrounds/bgPink.imageset/Contents.json new file mode 100644 index 0000000..5c0df9f --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/backgrounds/bgPink.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bgPink.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/backgrounds/bgPink.imageset/bgPink.png b/Tiny/Resources/Assets.xcassets/backgrounds/bgPink.imageset/bgPink.png new file mode 100644 index 0000000..7622d5c Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/backgrounds/bgPink.imageset/bgPink.png differ diff --git a/Tiny/Resources/Assets.xcassets/backgrounds/bgPurple.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/backgrounds/bgPurple.imageset/Contents.json new file mode 100644 index 0000000..fa2aa62 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/backgrounds/bgPurple.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bgPurple.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/backgrounds/bgPurple.imageset/bgPurple.png b/Tiny/Resources/Assets.xcassets/backgrounds/bgPurple.imageset/bgPurple.png new file mode 100644 index 0000000..e4267d8 Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/backgrounds/bgPurple.imageset/bgPurple.png differ diff --git a/Tiny/Resources/Assets.xcassets/bgPurple.imageset/Background Main.png b/Tiny/Resources/Assets.xcassets/bgPurple.imageset/Background Main.png new file mode 100644 index 0000000..30f900e Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/bgPurple.imageset/Background Main.png differ diff --git a/Tiny/Resources/Assets.xcassets/bgPurple.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/bgPurple.imageset/Contents.json new file mode 100644 index 0000000..15ef0ca --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/bgPurple.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Background Main.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/bokehColor/Contents.json b/Tiny/Resources/Assets.xcassets/bokehColor/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/bokehColor/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/bokehColor/bokehBlue.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/bokehColor/bokehBlue.colorset/Contents.json new file mode 100644 index 0000000..a7d5111 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/bokehColor/bokehBlue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xD2", + "red" : "0x8E" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xD2", + "red" : "0x8E" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/bokehColor/bokehGreen.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/bokehColor/bokehGreen.colorset/Contents.json new file mode 100644 index 0000000..6151025 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/bokehColor/bokehGreen.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x8E", + "green" : "0xFF", + "red" : "0xC1" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x8E", + "green" : "0xFF", + "red" : "0xC1" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/bokehColor/bokehPink.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/bokehColor/bokehPink.colorset/Contents.json new file mode 100644 index 0000000..0b9413f --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/bokehColor/bokehPink.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x97", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x97", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/bokehColor/bokehPurple.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/bokehColor/bokehPurple.colorset/Contents.json new file mode 100644 index 0000000..1c0c17d --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/bokehColor/bokehPurple.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x9C", + "red" : "0xBF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x9C", + "red" : "0xBF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/bokehColor/bokehYellow.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/bokehColor/bokehYellow.colorset/Contents.json new file mode 100644 index 0000000..23e2a6b --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/bokehColor/bokehYellow.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x6F", + "green" : "0xCF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x6F", + "green" : "0xCF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json new file mode 100644 index 0000000..7d97c94 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/mainViolet.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/Assets.xcassets/orbColor/Contents.json b/Tiny/Resources/Assets.xcassets/orbColor/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/orbColor/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/orbColor/orbBlue.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/orbColor/orbBlue.colorset/Contents.json new file mode 100644 index 0000000..e0a85ca --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/orbColor/orbBlue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF5", + "green" : "0x77", + "red" : "0x4D" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF5", + "green" : "0x77", + "red" : "0x4D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/orbColor/orbGreen.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/orbColor/orbGreen.colorset/Contents.json new file mode 100644 index 0000000..b63dbdb --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/orbColor/orbGreen.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x93", + "red" : "0x47" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x93", + "red" : "0x47" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/orbColor/orbPink.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/orbColor/orbPink.colorset/Contents.json new file mode 100644 index 0000000..84eca51 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/orbColor/orbPink.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x91", + "green" : "0x33", + "red" : "0x9C" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x91", + "green" : "0x33", + "red" : "0x9C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/orbColor/orbPurple.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/orbColor/orbPurple.colorset/Contents.json new file mode 100644 index 0000000..009c50e --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/orbColor/orbPurple.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x98", + "green" : "0x31", + "red" : "0x6C" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x98", + "green" : "0x31", + "red" : "0x6C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/orbColor/orbYellow.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/orbColor/orbYellow.colorset/Contents.json new file mode 100644 index 0000000..4d9b43e --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/orbColor/orbYellow.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2D", + "green" : "0x71", + "red" : "0x9A" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2D", + "green" : "0x71", + "red" : "0x9A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/peryWinkle.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/peryWinkle.colorset/Contents.json new file mode 100644 index 0000000..b050381 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/peryWinkle.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xCC", + "red" : "0xCC" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xCC", + "red" : "0xCC" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json new file mode 100644 index 0000000..c195a24 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 cb42ba6..1972be0 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -16,6 +16,18 @@ "comment" : "A text view displaying a number. The text inside the parentheses is the number to be displayed.", "isCommentAutoGenerated" : true }, + "%lld %@" : { + "comment" : "A text label displaying a week number, with an optional scale and opacity effect. The first argument is the week number. The second argument is a Boolean value indicating whether the text should be in a highlighted state. The third argument is a Boolean value indicating whether the text should be in a selected state. The fourth argument is a Boolean value indicating whether the text should be in a disabled state. The fifth argument is a", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld %2$@" + } + } + } + }, "%lld/%lld" : { "comment" : "A label showing the ratio of high-quality heartbeat detection samples to the total number of samples. The first argument is the count of high-quality samples. The second argument is the total count of samples.", "isCommentAutoGenerated" : true, @@ -107,6 +119,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 @@ -146,6 +162,14 @@ "comment" : "A button label that dismisses an alert.", "isCommentAutoGenerated" : true }, + "Change Profile Photo" : { + "comment" : "A title for the bottom sheet that appears when the user taps on their profile photo.", + "isCommentAutoGenerated" : true + }, + "Changes saved!" : { + "comment" : "A title displayed in an alert when a user successfully saves their changes to a recording.", + "isCommentAutoGenerated" : true + }, "Confidence" : { "comment" : "A label describing the confidence level of a heartbeat reading.", "isCommentAutoGenerated" : true @@ -154,6 +178,10 @@ "comment" : "A button label that, when tapped, will direct the user to configure Bluetooth on their device.", "isCommentAutoGenerated" : true }, + "Connect with Your Partner" : { + "comment" : "A call-to-action text that encourages users to connect with their partners.", + "isCommentAutoGenerated" : true + }, "Connect your AirPods and let Tiny access your microphone to hear every little beat." : { "comment" : "A description of how to connect AirPods to Tiny.", "isCommentAutoGenerated" : true @@ -162,13 +190,39 @@ "comment" : "A label displayed next to the name of the currently connected audio device.", "isCommentAutoGenerated" : true }, + "Connected Journey" : { + "comment" : "A feature card title.", + "isCommentAutoGenerated" : true + }, "Continue" : { "comment" : "A button label that says \"Continue\" when a user pauses listening to a podcast.", "isCommentAutoGenerated" : true }, + "Control your screen with a simple gesture:" : { + + }, + "Copied!" : { + "comment" : "A message displayed when the user successfully copies the room 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 + }, + "Delete" : { + "comment" : "The text on a button that deletes an item.", + "isCommentAutoGenerated" : true + }, + "Delete this moment?" : { + "comment" : "The title of the alert that appears when the user tries to delete a moment.", + "isCommentAutoGenerated" : true + }, + "Deleted." : { + }, "Detection Confidence" : { "comment" : "A section header describing the confidence of the heartbeat data analysis.", @@ -182,19 +236,51 @@ "comment" : "A text label that appears when the user drags their finger over the orb to save a new recording.", "isCommentAutoGenerated" : true }, + "Drag up to delete" : { + + }, + "Dummy Theme View" : { + "comment" : "A placeholder view for a theme-related screen.", + "isCommentAutoGenerated" : true + }, + "Dummy Tutorial View" : { + "comment" : "A placeholder text indicating a view that will be replaced with a tutorial view.", + "isCommentAutoGenerated" : true + }, + "Edit Profile" : { + "comment" : "The title of the view that allows users to edit their profile information.", + "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." : { + "comment" : "A description below the text field where the user inputs the room code.", + "isCommentAutoGenerated" : true + }, + "Enter your name" : { + "comment" : "A placeholder text for a text field where a user can enter their name.", + "isCommentAutoGenerated" : true + }, "EQ Configuration" : { - + "comment" : "A heading for the section of the audio post-processing test interface that lists the user's EQ settings.", + "isCommentAutoGenerated" : true }, "EQ Settings Applied" : { - + "comment" : "A description of the EQ settings that have been applied to the audio.", + "isCommentAutoGenerated" : true }, "EQ Test" : { "comment" : "A tab label for the audio processing test view.", "isCommentAutoGenerated" : true + }, + "Every pregnancy is beautifully different, so results may vary. Be gentle with yourself if it doesn’t work right away." : { + }, "Export CSV" : { "comment" : "A button to export the processed heartbeat data to a CSV file.", @@ -209,12 +295,17 @@ "isCommentAutoGenerated" : true }, "Finish session" : { - + "comment" : "A label displayed next to a button that pauses a session.", + "isCommentAutoGenerated" : true }, "Frequency Spectrum" : { "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 @@ -251,6 +342,10 @@ "comment" : "A label describing the number of high-quality heartbeat detections.", "isCommentAutoGenerated" : true }, + "Hold sphere to stop session" : { + "comment" : "A text displayed below the \"Listening...\" text, instructing the user to hold the orb to stop the current recording session.", + "isCommentAutoGenerated" : true + }, "Hold then drag the sphere" : { "comment" : "A description of how to save or delete a recording.", "isCommentAutoGenerated" : true @@ -259,6 +354,14 @@ "comment" : "A label displayed in the center of the countdown view, instructing the user to hold their finger to stop the countdown.", "isCommentAutoGenerated" : true }, + "How far along are you?" : { + "comment" : "A title displayed above the view that asks the user how far along they are.", + "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 @@ -267,6 +370,14 @@ "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 + }, + "Keep" : { + "comment" : "A button that cancels the deletion of a moment.", + "isCommentAutoGenerated" : true + }, "Key" : { "extractionState" : "manual" }, @@ -278,6 +389,10 @@ "comment" : "A label displayed above the option to share the user's last recording.", "isCommentAutoGenerated" : true }, + "Let's begin" : { + "comment" : "A button label that says \"Let's begin\".", + "isCommentAutoGenerated" : true + }, "Let's go" : { "comment" : "A button label that says \"Let's go\" and is used to proceed to the next page of the onboarding flow.", "isCommentAutoGenerated" : true @@ -302,12 +417,21 @@ "comment" : "The title of an alert that appears when microphone access is denied.", "isCommentAutoGenerated" : true }, + "Name" : { + "comment" : "A label describing the user's name.", + "isCommentAutoGenerated" : true + }, "No heartbeat detected yet" : { "comment" : "A message displayed when no heartbeat data is available.", "isCommentAutoGenerated" : true }, "No recording available. Record in Orb mode first." : { - + "comment" : "A message displayed when there is no recorded heartbeat sound to process. It encourages the user to record a heartbeat sound first.", + "isCommentAutoGenerated" : true + }, + "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.", @@ -329,22 +453,33 @@ "comment" : "A button label that pauses a recording.", "isCommentAutoGenerated" : true }, + "Photo Library" : { + "comment" : "A button that allows the user to select an image from their photo library.", + "isCommentAutoGenerated" : true + }, "Play" : { "comment" : "A button label that says \"Play\".", "isCommentAutoGenerated" : true }, "Play Last Recording with EQ" : { - + "comment" : "A button label that allows the user to play a recording with equalization applied.", + "isCommentAutoGenerated" : true }, "Play or Pause" : { "comment" : "A label displayed next to a play/pause button in the tutorial overlay.", "isCommentAutoGenerated" : true }, + "Playing" : { + "comment" : "A text that appears when a recording is being played.", + "isCommentAutoGenerated" : true + }, "Playing with EQ" : { - + "comment" : "A message indicating that audio is being played with equalization applied.", + "isCommentAutoGenerated" : true }, "Playing..." : { - + "comment" : "A label indicating that audio is currently playing.", + "isCommentAutoGenerated" : true }, "Please enable microphone access in Settings to use this feature." : { "comment" : "An alert message explaining that microphone access is needed to use the app.", @@ -358,6 +493,10 @@ "comment" : "A description of how to finish a session by pressing and holding a sphere.", "isCommentAutoGenerated" : true }, + "Privacy Policy" : { + "comment" : "A label for a privacy policy option in the profile settings.", + "isCommentAutoGenerated" : true + }, "Proximity Gain" : { "comment" : "A label displayed next to the value of the proximity gain slider.", "isCommentAutoGenerated" : true @@ -367,12 +506,17 @@ "isCommentAutoGenerated" : true }, "Range" : { - + "comment" : "A label describing the range of a statistic.", + "isCommentAutoGenerated" : true }, "Recent Detections" : { "comment" : "A header for the most recent heartbeat detections.", "isCommentAutoGenerated" : true }, + "Recording Name" : { + "comment" : "A label above the text field for editing a saved recording's name.", + "isCommentAutoGenerated" : true + }, "Recording: %@" : { "comment" : "A text label displaying the name of the most recently recorded audio file.", "isCommentAutoGenerated" : true @@ -381,6 +525,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 @@ -409,6 +557,10 @@ "comment" : "A label for the number of samples taken in the current session.", "isCommentAutoGenerated" : true }, + "Save" : { + "comment" : "A button to save changes made to the user's profile.", + "isCommentAutoGenerated" : true + }, "Save or Delete" : { "comment" : "A caption displayed underneath a coach mark in the tutorial overlay.", "isCommentAutoGenerated" : true @@ -429,6 +581,18 @@ "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 + }, + "Sign Out" : { + "comment" : "A button that signs the user out of their account.", + "isCommentAutoGenerated" : true + }, "Signal Amplitude" : { "comment" : "A heading for the signal amplitude indicator.", "isCommentAutoGenerated" : true @@ -453,6 +617,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 @@ -497,6 +669,10 @@ "comment" : "A label indicating that the playback has been stopped.", "isCommentAutoGenerated" : true }, + "Take Photo" : { + "comment" : "The text for a button that allows the user to take a photo.", + "isCommentAutoGenerated" : true + }, "Tap orb to play" : { "comment" : "A text displayed when the user is not listening to audio and is not in playback mode. It instructs the user to tap the orb to play audio.", "isCommentAutoGenerated" : true @@ -513,8 +689,28 @@ "comment" : "A call-to-action label that appears at the bottom of the tutorial overlay, instructing the user to tap to continue.", "isCommentAutoGenerated" : true }, + "Tap to play" : { + "comment" : "A text displayed when a user taps on a recording to play it.", + "isCommentAutoGenerated" : true + }, "Tap twice" : { + }, + "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.", + "isCommentAutoGenerated" : true + }, + "Theme" : { + "comment" : "A button that allows the user to change the app's theme.", + "isCommentAutoGenerated" : true + }, + "This action is permanent and can't be undone." : { + "comment" : "The message displayed in the alert when deleting a moment.", + "isCommentAutoGenerated" : true }, "Time" : { "comment" : "Data point on the heart rate trend chart.", @@ -524,10 +720,22 @@ "comment" : "A section header that describes the time range selection feature.", "isCommentAutoGenerated" : true }, + "Tiny" : { + "comment" : "A title displayed at the top of the page.", + "isCommentAutoGenerated" : true + }, + "Tiny isn’t a medical app" : { + "comment" : "A footnote explaining that Tiny is not a medical app.", + "isCommentAutoGenerated" : true + }, "Tiny will need access to your microphone so you can hear every tiny beat clearly." : { "comment" : "A description under the title of the second onboarding page.", "isCommentAutoGenerated" : true }, + "Tutorial" : { + "comment" : "A link to a view that displays a tutorial.", + "isCommentAutoGenerated" : true + }, "Variability" : { "comment" : "A label for the variability of a user's heart rate.", "isCommentAutoGenerated" : true @@ -536,13 +744,45 @@ "comment" : "A label inside the bottom pocket of a folder, showing the current week.", "isCommentAutoGenerated" : true }, + "Weeks" : { + "comment" : "A unit of measurement for pregnancy weeks.", + "isCommentAutoGenerated" : true + }, + "You can always find guides and info in your profile later." : { + "comment" : "A text displayed below the \"Let's Begin!\" title, instructing the user that they can find more information in their profile later.", + "isCommentAutoGenerated" : true + }, "You can listen to your baby's heartbeat live and record it to listen again later." : { "comment" : "A description of the live and recorded heartbeat features.", "isCommentAutoGenerated" : true }, + "You'll need to sign in again to sync your data and access personalized features." : { + "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, explaining that they are connected to a room.", + "isCommentAutoGenerated" : true + }, + "Your changes is saved." : { + "comment" : "A description of what happens when they save their name edit.", + "isCommentAutoGenerated" : true + }, + "your gentle guide" : { + "comment" : "A description of what Tiny is.", + "isCommentAutoGenerated" : true + }, + "Your recording is deleted." : { + "comment" : "A message displayed after successfully deleting a recording.", + "isCommentAutoGenerated" : true + }, "Your recording will be saved automatically in your library." : { "comment" : "A description of what happens when the user stops a recording and it is saved.", "isCommentAutoGenerated" : true + }, + "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 + + +