diff --git a/CLAUDE.md b/CLAUDE.md index 5ab41d7..878aa6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ PortfolioJournal/ │ ├── Security/ # Face ID/PIN lock (AppLockView) │ └── Components/ # Shared UI components ├── Utilities/ # Helpers: KeychainService, FreemiumValidator, formatters, extensions -└── Resources/ # Info.plist, GoogleService-Info.plist, assets +└── Resources/ # Info.plist, PrivacyInfo.xcprivacy, assets PortfolioJournalWidget/ # iOS Home Screen Widget (WidgetKit) PortfolioJournalWatch/ # watchOS app (read-only portfolio view) @@ -84,23 +84,25 @@ Data flows through `CoreDataStack` singleton which manages CloudKit sync and App - **CalculationService**: Portfolio metrics, returns calculation, allocation analysis - **PredictionEngine**: Investment forecasting algorithms with caching - **IAPService**: StoreKit 2 in-app purchases -- **AdMobService**: Google Mobile Ads integration +- **DiagnosticsCollector**: on-device MetricKit crash/hang reports (nothing is uploaded) - **ImportService/ExportService**: CSV data import/export - **AppLockService**: Biometric/PIN security via Keychain ## Dependencies -Managed via Swift Package Manager: -- Firebase iOS SDK (v12.7.0+) - Analytics -- Google Mobile Ads SDK +**No third-party packages.** The app deliberately links nothing outside Apple's SDKs: +AdMob, Firebase Analytics and Crashlytics were removed in 1.7.0 so that the +"no tracking, no analytics" claim on the App Store listing is literally true. +Do not reintroduce an advertising, analytics or crash-reporting SDK. -Native frameworks: SwiftUI, Combine, CoreData, CloudKit, WidgetKit, StoreKit 2, LocalAuthentication +Native frameworks: SwiftUI, Combine, CoreData, CloudKit, WidgetKit, StoreKit 2, +LocalAuthentication, MetricKit ## App Initialization Flow ``` PortfolioJournalApp (@main) - └── AppDelegate (Firebase, AdMob, Notifications init) + └── AppDelegate (MetricKit diagnostics, Notifications init) └── ContentView ├── OnboardingView (first launch) ├── AppLockView (if security enabled) diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index 107e523..caee36e 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -10,11 +10,7 @@ 0E241ECF2F0DAA3C00283E2F /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */; }; 0E241ED12F0DAA3C00283E2F /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */; }; 0E241EE22F0DAA3E00283E2F /* PortfolioJournalWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752A2F0FD08100F31390 /* FirebaseCore */; }; - 0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752C2F0FD08600F31390 /* FirebaseAnalytics */; }; 0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E53752E2F0FD09F00F31390 /* CoreData.framework */; }; - 0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375302F0FD12E00F31390 /* GoogleMobileAds */; }; - 0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375342F0FD14000F31390 /* FirebaseCrashlytics */; }; 0EQUPD102F40000000000001 /* PortfolioJournalQuickUpdate.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 0EWTCH190000000000000000 /* PortfolioJournalWatchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 0EWTCH250000000000000000 /* PortfolioJournalWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 0EWTCH030000000000000000 /* PortfolioJournalWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -246,11 +242,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */, - 0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */, 0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */, - 0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */, - 0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -355,7 +347,6 @@ 0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */, 0E8318932F0DB2FB0030C2F9 /* Resources */, 0EWTCH240000000000000000 /* Embed Watch Content */, - 0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */, ); buildRules = ( ); @@ -368,12 +359,6 @@ 0E241E3B2F0DA93A00283E2F /* PortfolioJournal */, ); name = PortfolioJournal; - packageProductDependencies = ( - 0E53752A2F0FD08100F31390 /* FirebaseCore */, - 0E53752C2F0FD08600F31390 /* FirebaseAnalytics */, - 0E5375302F0FD12E00F31390 /* GoogleMobileAds */, - 0E5375342F0FD14000F31390 /* FirebaseCrashlytics */, - ); productName = PortfolioJournal; productReference = 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */; productType = "com.apple.product-type.application"; @@ -394,8 +379,6 @@ 0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */, ); name = PortfolioJournalWidgetExtension; - packageProductDependencies = ( - ); productName = PortfolioJournalWidgetExtension; productReference = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -417,8 +400,6 @@ 0ETEST0032F31000000000001 /* PortfolioJournalTests */, ); name = PortfolioJournalTests; - packageProductDependencies = ( - ); productName = PortfolioJournalTests; productReference = 0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -440,8 +421,6 @@ 0EUITEST032F31000000001 /* PortfolioJournalUITests */, ); name = PortfolioJournalUITests; - packageProductDependencies = ( - ); productName = PortfolioJournalUITests; productReference = 0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; @@ -462,8 +441,6 @@ 0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */, ); name = PortfolioJournalQuickUpdate; - packageProductDependencies = ( - ); productName = PortfolioJournalQuickUpdate; productReference = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */; productType = "com.apple.product-type.app-extension"; @@ -487,8 +464,6 @@ 0EWTCH070000000000000000 /* Shared */, ); name = PortfolioJournalWatch; - packageProductDependencies = ( - ); productName = PortfolioJournalWatch; productReference = 0EWTCH030000000000000000 /* PortfolioJournalWatch.app */; productType = "com.apple.product-type.application"; @@ -509,8 +484,6 @@ 0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */, ); name = PortfolioJournalWatchWidget; - packageProductDependencies = ( - ); productName = PortfolioJournalWatchWidget; productReference = 0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */; productType = "com.apple.product-type.app-extension"; @@ -555,10 +528,6 @@ ); mainGroup = 0E241E302F0DA93A00283E2F; minimizedProjectReferenceProxies = 1; - packageReferences = ( - 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, - 0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */, - ); preferredProjectObjectVersion = 77; productRefGroup = 0E241E3A2F0DA93A00283E2F /* Products */; projectDirPath = ""; @@ -679,28 +648,6 @@ }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - 0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}", - "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", - ); - name = "Upload dSYMs to Crashlytics"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n"; - }; -/* End PBXShellScriptBuildPhase section */ /* Begin PBXTargetDependency section */ 0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */ = { @@ -742,7 +689,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements; - ENABLE_USER_SCRIPT_SANDBOXING = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 89; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; @@ -782,7 +729,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements; - ENABLE_USER_SCRIPT_SANDBOXING = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal"; @@ -1372,47 +1319,7 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 12.7.0; - }; - }; - 0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/googleads/swift-package-manager-google-mobile-ads"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 12.14.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ -/* Begin XCSwiftPackageProductDependency section */ - 0E53752A2F0FD08100F31390 /* FirebaseCore */ = { - isa = XCSwiftPackageProductDependency; - package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseCore; - }; - 0E53752C2F0FD08600F31390 /* FirebaseAnalytics */ = { - isa = XCSwiftPackageProductDependency; - package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalytics; - }; - 0E5375302F0FD12E00F31390 /* GoogleMobileAds */ = { - isa = XCSwiftPackageProductDependency; - package = 0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */; - productName = GoogleMobileAds; - }; - 0E5375342F0FD14000F31390 /* FirebaseCrashlytics */ = { - isa = XCSwiftPackageProductDependency; - package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseCrashlytics; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = 0E241E312F0DA93A00283E2F /* Project object */; } diff --git a/PortfolioJournal.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PortfolioJournal.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 6879033..0000000 --- a/PortfolioJournal.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,150 +0,0 @@ -{ - "originHash" : "2d3f06aab1ccdfff7b100ca10208a834bfb664b3c50890610f1e3c349eb3fbf3", - "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" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "45210bd1ea695779e6de016ab00fea8c0b7eb2ef", - "version" : "12.7.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" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", - "version" : "2.4.0" - } - }, - { - "identity" : "swift-package-manager-google-mobile-ads", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/swift-package-manager-google-mobile-ads", - "state" : { - "revision" : "1239c23464503053c17d37ee5daa70de3455e9c8", - "version" : "12.14.0" - } - }, - { - "identity" : "swift-package-manager-google-user-messaging-platform", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/swift-package-manager-google-user-messaging-platform.git", - "state" : { - "revision" : "13b248eaa73b7826f0efb1bcf455e251d65ecb1b", - "version" : "3.1.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/PortfolioJournal/App/AppDelegate.swift b/PortfolioJournal/App/AppDelegate.swift index 9984f0d..69abc30 100644 --- a/PortfolioJournal/App/AppDelegate.swift +++ b/PortfolioJournal/App/AppDelegate.swift @@ -1,25 +1,11 @@ import UIKit import UserNotifications -import FirebaseCore -import FirebaseAnalytics -import FirebaseCrashlytics -import GoogleMobileAds class AppDelegate: NSObject, UIApplicationDelegate { func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { - // Initialize Firebase (only if GoogleService-Info.plist exists) - if Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil { - FirebaseApp.configure() - } else { - print("Warning: GoogleService-Info.plist not found. Firebase disabled.") - } - - // Initialize Google Mobile Ads - MobileAds.shared.start() - // Request notification permissions requestNotificationPermissions() diff --git a/PortfolioJournal/App/ContentView.swift b/PortfolioJournal/App/ContentView.swift index 512a41b..af95d09 100644 --- a/PortfolioJournal/App/ContentView.swift +++ b/PortfolioJournal/App/ContentView.swift @@ -31,7 +31,6 @@ enum AppTab: Int, Hashable, CaseIterable { struct ContentView: View { @EnvironmentObject var iapService: IAPService - @EnvironmentObject var adMobService: AdMobService @EnvironmentObject var tabSelection: TabSelectionStore @EnvironmentObject var coreDataStack: CoreDataStack @AppStorage("onboardingCompleted") private var onboardingCompleted = false @@ -288,26 +287,15 @@ struct ContentView: View { private func tabContent(for tab: AppTab) -> some View { switch tab { case .dashboard: - bannerInsetView(DashboardView()) + DashboardView() case .sources: - bannerInsetView(SourceListView(iapService: iapService)) + SourceListView(iapService: iapService) case .charts: - bannerInsetView(ChartsContainerView(iapService: iapService)) + ChartsContainerView(iapService: iapService) case .journal: - bannerInsetView(JournalView()) + JournalView() case .settings: - bannerInsetView(SettingsView(iapService: iapService)) - } - } - - private func bannerInsetView(_ content: Content) -> some View { - content.safeAreaInset(edge: .bottom, spacing: 0) { - if !iapService.isPremium && adMobService.canShowAds { - BannerAdView() - .frame(height: AppConstants.UI.bannerAdHeight) - .frame(maxWidth: .infinity) - .background(Color(.systemBackground)) - } + SettingsView(iapService: iapService) } } } @@ -316,7 +304,6 @@ struct ContentView: View { ContentView() .environmentObject(CoreDataStack.shared) .environmentObject(IAPService()) - .environmentObject(AdMobService()) .environmentObject(AccountStore(iapService: IAPService())) .environmentObject(TabSelectionStore()) .environmentObject(BalancePrivacyManager.shared) diff --git a/PortfolioJournal/App/PortfolioJournalApp.swift b/PortfolioJournal/App/PortfolioJournalApp.swift index 371efaf..3744e4a 100644 --- a/PortfolioJournal/App/PortfolioJournalApp.swift +++ b/PortfolioJournal/App/PortfolioJournalApp.swift @@ -62,7 +62,6 @@ enum ScreenshotMode { struct PortfolioJournalApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var iapService: IAPService - @StateObject private var adMobService: AdMobService @StateObject private var accountStore: AccountStore @StateObject private var tabSelection = TabSelectionStore() @StateObject private var balancePrivacy = BalancePrivacyManager.shared @@ -83,7 +82,6 @@ struct PortfolioJournalApp: App { let iap = IAPService() _iapService = StateObject(wrappedValue: iap) - _adMobService = StateObject(wrappedValue: AdMobService()) _accountStore = StateObject(wrappedValue: AccountStore(iapService: iap)) } @@ -93,7 +91,6 @@ struct PortfolioJournalApp: App { .environment(\.managedObjectContext, coreDataStack.viewContext) .environmentObject(coreDataStack) .environmentObject(iapService) - .environmentObject(adMobService) .environmentObject(accountStore) .environmentObject(tabSelection) .environmentObject(balancePrivacy) diff --git a/PortfolioJournal/Models/CoreData/AppSettings+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/AppSettings+CoreDataClass.swift index e972494..4b50bf4 100644 --- a/PortfolioJournal/Models/CoreData/AppSettings+CoreDataClass.swift +++ b/PortfolioJournal/Models/CoreData/AppSettings+CoreDataClass.swift @@ -10,6 +10,9 @@ public class AppSettings: NSManagedObject, Identifiable { @NSManaged public var id: UUID @NSManaged public var currency: String @NSManaged public var defaultNotificationTime: Date? + /// Unused since 1.7.0: there is no analytics of any kind to enable or + /// disable. The attribute stays only because dropping it would mean a Core + /// Data migration plus a CloudKit schema deploy. Do not wire it to anything. @NSManaged public var enableAnalytics: Bool @NSManaged public var onboardingCompleted: Bool @NSManaged public var lastSyncDate: Date? diff --git a/PortfolioJournal/Resources/GoogleService-Info.plist b/PortfolioJournal/Resources/GoogleService-Info.plist deleted file mode 100644 index 45950bc..0000000 --- a/PortfolioJournal/Resources/GoogleService-Info.plist +++ /dev/null @@ -1,30 +0,0 @@ - - - - - API_KEY - AIzaSyAregLaXQ-WqRQTltTRyjQx3-lfxLl4Cng - GCM_SENDER_ID - 334225114072 - PLIST_VERSION - 1 - BUNDLE_ID - com.alexandrevazquez.PortfolioJournal - PROJECT_ID - portfoliojournal-ef2d7 - STORAGE_BUCKET - portfoliojournal-ef2d7.firebasestorage.app - IS_ADS_ENABLED - - IS_ANALYTICS_ENABLED - - IS_APPINVITE_ENABLED - - IS_GCM_ENABLED - - IS_SIGNIN_ENABLED - - GOOGLE_APP_ID - 1:334225114072:ios:a0a50b5835ac042e3d28ad - - \ No newline at end of file diff --git a/PortfolioJournal/Resources/Info.plist b/PortfolioJournal/Resources/Info.plist index 180cadc..df8428e 100644 --- a/PortfolioJournal/Resources/Info.plist +++ b/PortfolioJournal/Resources/Info.plist @@ -31,10 +31,6 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) - GADApplicationIdentifier - ca-app-pub-1549720748100858~9632507420 - GADDelayAppMeasurementInit - ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS @@ -54,271 +50,6 @@ Use Face ID to unlock your portfolio data. NSUbiquitousContainerIsDocumentScopePublic - NSUserTrackingUsageDescription - This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties. - SKAdNetworkItems - - - SKAdNetworkIdentifier - cstr6suwn9.skadnetwork - - - SKAdNetworkIdentifier - 4fzdc2evr5.skadnetwork - - - SKAdNetworkIdentifier - 4pfyvq9l8r.skadnetwork - - - SKAdNetworkIdentifier - 2fnua5tdw4.skadnetwork - - - SKAdNetworkIdentifier - ydx93a7ass.skadnetwork - - - SKAdNetworkIdentifier - 5a6flpkh64.skadnetwork - - - SKAdNetworkIdentifier - p78axxw29g.skadnetwork - - - SKAdNetworkIdentifier - v72qych5uu.skadnetwork - - - SKAdNetworkIdentifier - ludvb6z3bs.skadnetwork - - - SKAdNetworkIdentifier - cp8zw746q7.skadnetwork - - - SKAdNetworkIdentifier - 3sh42y64q3.skadnetwork - - - SKAdNetworkIdentifier - c6k4g5qg8m.skadnetwork - - - SKAdNetworkIdentifier - s39g8k73mm.skadnetwork - - - SKAdNetworkIdentifier - 3qy4746246.skadnetwork - - - SKAdNetworkIdentifier - f38h382jlk.skadnetwork - - - SKAdNetworkIdentifier - hs6bdukanm.skadnetwork - - - SKAdNetworkIdentifier - v4nxqhlyqp.skadnetwork - - - SKAdNetworkIdentifier - wzmmz9fp6w.skadnetwork - - - SKAdNetworkIdentifier - yclnxrl5pm.skadnetwork - - - SKAdNetworkIdentifier - t38b2kh725.skadnetwork - - - SKAdNetworkIdentifier - 7ug5zh24hu.skadnetwork - - - SKAdNetworkIdentifier - gta9lk7p23.skadnetwork - - - SKAdNetworkIdentifier - vutu7akeur.skadnetwork - - - SKAdNetworkIdentifier - y5ghdn5j9k.skadnetwork - - - SKAdNetworkIdentifier - n6fk4nfna4.skadnetwork - - - SKAdNetworkIdentifier - v9wttpbfk9.skadnetwork - - - SKAdNetworkIdentifier - n38lu8286q.skadnetwork - - - SKAdNetworkIdentifier - 47vhws6wlr.skadnetwork - - - SKAdNetworkIdentifier - kbd757ber7.skadnetwork - - - SKAdNetworkIdentifier - 9t245vhmpl.skadnetwork - - - SKAdNetworkIdentifier - eh6m2bh4zr.skadnetwork - - - SKAdNetworkIdentifier - a2p9lx4jpn.skadnetwork - - - SKAdNetworkIdentifier - 22mmun2rn5.skadnetwork - - - SKAdNetworkIdentifier - 4468km3ulz.skadnetwork - - - SKAdNetworkIdentifier - 2u9pt9hc89.skadnetwork - - - SKAdNetworkIdentifier - 8s468mfl3y.skadnetwork - - - SKAdNetworkIdentifier - klf5c3l5u5.skadnetwork - - - SKAdNetworkIdentifier - ppxm28t8ap.skadnetwork - - - SKAdNetworkIdentifier - ecpz2srf59.skadnetwork - - - SKAdNetworkIdentifier - uw77j35x4d.skadnetwork - - - SKAdNetworkIdentifier - pwa73g5rt2.skadnetwork - - - SKAdNetworkIdentifier - mlmmfzh3r3.skadnetwork - - - SKAdNetworkIdentifier - 578prtvx9j.skadnetwork - - - SKAdNetworkIdentifier - 4dzt52r2t5.skadnetwork - - - SKAdNetworkIdentifier - e5fvkxwrpn.skadnetwork - - - SKAdNetworkIdentifier - 8c4e2ghe7u.skadnetwork - - - SKAdNetworkIdentifier - zq492l623r.skadnetwork - - - SKAdNetworkIdentifier - 3rd42ekr43.skadnetwork - - - SKAdNetworkIdentifier - 3qcr597p9d.skadnetwork - - - SKAdNetworkIdentifier - su67r6k2v3.skadnetwork - - - SKAdNetworkIdentifier - kbd757ywx3.skadnetwork - - - SKAdNetworkIdentifier - 44n7hlldy6.skadnetwork - - - SKAdNetworkIdentifier - k674qkevps.skadnetwork - - - SKAdNetworkIdentifier - 2rq3zucswp.skadnetwork - - - SKAdNetworkIdentifier - 5tjdwbrq8w.skadnetwork - - - SKAdNetworkIdentifier - 97r2b46745.skadnetwork - - - SKAdNetworkIdentifier - b9bk5wbcq9.skadnetwork - - - SKAdNetworkIdentifier - 44jx6755aq.skadnetwork - - - SKAdNetworkIdentifier - kbmxgpxpgc.skadnetwork - - - SKAdNetworkIdentifier - tl55sbb4fm.skadnetwork - - - SKAdNetworkIdentifier - 9g2aggbj52.skadnetwork - - - SKAdNetworkIdentifier - g2y4y55b64.skadnetwork - - - SKAdNetworkIdentifier - c3frkrj4fj.skadnetwork - - - SKAdNetworkIdentifier - r26jy69rpl.skadnetwork - - - SKAdNetworkIdentifier - 275upjj5gd.skadnetwork - - UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/PortfolioJournal/Resources/de.lproj/Localizable.strings b/PortfolioJournal/Resources/de.lproj/Localizable.strings index ab163ed..b44cd0c 100644 --- a/PortfolioJournal/Resources/de.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/de.lproj/Localizable.strings @@ -85,16 +85,12 @@ "feature_predictions_desc" = "KI-gestützte 12-Monats-Prognosen"; "feature_export" = "Daten exportieren"; "feature_export_desc" = "Export nach CSV und JSON"; -"feature_no_ads" = "Keine Werbung"; -"feature_no_ads_desc" = "Dauerhaft werbefrei"; "paywall_benefit_history_title" = "Deine vollständige Historie"; "paywall_benefit_history_subtitle" = "Jeder Snapshot, Beitrag und Gewinn seit Tag eins"; "paywall_benefit_charts_title" = "Charts, die Muster zeigen"; "paywall_benefit_charts_subtitle" = "Allokation, Drawdown, Performance, alles an einem Ort"; "paywall_benefit_forecasts_title" = "12-Monats-Prognosen"; "paywall_benefit_forecasts_subtitle" = "Sieh, wohin sich dein Portfolio wahrscheinlich entwickelt"; -"paywall_benefit_noads_title" = "Keine Werbung, jemals"; -"paywall_benefit_noads_subtitle" = "Klares, fokussiertes Erlebnis ohne Ablenkung"; "settings_title" = "Einstellungen"; "subscription" = "Abo"; "notifications" = "Benachrichtigungen"; diff --git a/PortfolioJournal/Resources/en.lproj/Localizable.strings b/PortfolioJournal/Resources/en.lproj/Localizable.strings index d9273b7..26480f7 100644 --- a/PortfolioJournal/Resources/en.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/en.lproj/Localizable.strings @@ -109,8 +109,6 @@ "feature_predictions_desc" = "AI-powered 12-month forecasts"; "feature_export" = "Export Data"; "feature_export_desc" = "Export to CSV and JSON formats"; -"feature_no_ads" = "No Ads"; -"feature_no_ads_desc" = "Ad-free experience forever"; // Paywall benefits (1.2.0) "paywall_benefit_history_title" = "Your complete history"; @@ -119,8 +117,6 @@ "paywall_benefit_charts_subtitle" = "Allocation, drawdown, performance — all in one place"; "paywall_benefit_forecasts_title" = "12-month forecasts"; "paywall_benefit_forecasts_subtitle" = "See where your portfolio is likely heading"; -"paywall_benefit_noads_title" = "No ads, ever"; -"paywall_benefit_noads_subtitle" = "Clean, focused experience with no distractions"; // MARK: - Settings "settings_title" = "Settings"; diff --git a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings index 7563f35..985197d 100644 --- a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings @@ -154,8 +154,6 @@ "paywall_benefit_charts_subtitle" = "Asignación, drawdown, rendimiento — todo en un lugar"; "paywall_benefit_forecasts_title" = "Previsiones a 12 meses"; "paywall_benefit_forecasts_subtitle" = "Ve a dónde se dirige tu cartera"; -"paywall_benefit_noads_title" = "Sin anuncios, nunca"; -"paywall_benefit_noads_subtitle" = "Una experiencia limpia y sin distracciones"; // MARK: - Onboarding pages (1.2.0) "onboarding_clarity_title" = "Sabe exactamente dónde estás"; diff --git a/PortfolioJournal/Resources/fr.lproj/Localizable.strings b/PortfolioJournal/Resources/fr.lproj/Localizable.strings index f0a241c..e9403c7 100644 --- a/PortfolioJournal/Resources/fr.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/fr.lproj/Localizable.strings @@ -85,16 +85,12 @@ "feature_predictions_desc" = "Prévisions IA sur 12 mois"; "feature_export" = "Exporter les données"; "feature_export_desc" = "Export vers CSV et JSON"; -"feature_no_ads" = "Sans publicité"; -"feature_no_ads_desc" = "Expérience sans publicité à vie"; "paywall_benefit_history_title" = "Votre historique complet"; "paywall_benefit_history_subtitle" = "Chaque snapshot, contribution et gain depuis le premier jour"; "paywall_benefit_charts_title" = "Des graphiques qui révèlent des tendances"; "paywall_benefit_charts_subtitle" = "Allocation, drawdown, performance, le tout au même endroit"; "paywall_benefit_forecasts_title" = "Prévisions à 12 mois"; "paywall_benefit_forecasts_subtitle" = "Voyez où votre portefeuille pourrait aller"; -"paywall_benefit_noads_title" = "Aucune pub, jamais"; -"paywall_benefit_noads_subtitle" = "Une expérience propre et centrée, sans distractions"; "settings_title" = "Réglages"; "subscription" = "Abonnement"; "notifications" = "Notifications"; diff --git a/PortfolioJournal/Resources/it.lproj/Localizable.strings b/PortfolioJournal/Resources/it.lproj/Localizable.strings index 9c0e661..7c344d7 100644 --- a/PortfolioJournal/Resources/it.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/it.lproj/Localizable.strings @@ -85,16 +85,12 @@ "feature_predictions_desc" = "Previsioni a 12 mesi basate su IA"; "feature_export" = "Esporta dati"; "feature_export_desc" = "Esporta in CSV e JSON"; -"feature_no_ads" = "Niente pubblicità"; -"feature_no_ads_desc" = "Esperienza senza pubblicità per sempre"; "paywall_benefit_history_title" = "La tua cronologia completa"; "paywall_benefit_history_subtitle" = "Ogni snapshot, contributo e guadagno dal primo giorno"; "paywall_benefit_charts_title" = "Grafici che rivelano schemi"; "paywall_benefit_charts_subtitle" = "Allocazione, drawdown, performance: tutto in un unico posto"; "paywall_benefit_forecasts_title" = "Previsioni a 12 mesi"; "paywall_benefit_forecasts_subtitle" = "Guarda dove probabilmente andrà il tuo portafoglio"; -"paywall_benefit_noads_title" = "Niente pubblicità, mai"; -"paywall_benefit_noads_subtitle" = "Esperienza pulita e focalizzata, senza distrazioni"; "settings_title" = "Impostazioni"; "subscription" = "Abbonamento"; "notifications" = "Notifiche"; diff --git a/PortfolioJournal/Resources/ja.lproj/Localizable.strings b/PortfolioJournal/Resources/ja.lproj/Localizable.strings index 03e83f1..2d132e9 100644 --- a/PortfolioJournal/Resources/ja.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/ja.lproj/Localizable.strings @@ -85,16 +85,12 @@ "feature_predictions_desc" = "AI による12か月予測"; "feature_export" = "データを書き出し"; "feature_export_desc" = "CSV と JSON に書き出し"; -"feature_no_ads" = "広告なし"; -"feature_no_ads_desc" = "ずっと広告なしで使えます"; "paywall_benefit_history_title" = "完全な履歴"; "paywall_benefit_history_subtitle" = "初日からのすべての snapshot、追加資金、利益を確認"; "paywall_benefit_charts_title" = "傾向が見えるチャート"; "paywall_benefit_charts_subtitle" = "配分、ドローダウン、パフォーマンスを一か所で確認"; "paywall_benefit_forecasts_title" = "12か月予測"; "paywall_benefit_forecasts_subtitle" = "ポートフォリオの行き先を見通せます"; -"paywall_benefit_noads_title" = "広告は一切なし"; -"paywall_benefit_noads_subtitle" = "気が散らない、集中できる体験"; "settings_title" = "設定"; "subscription" = "サブスクリプション"; "notifications" = "通知"; diff --git a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings index beed04f..b47c7aa 100644 --- a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings @@ -85,16 +85,12 @@ "feature_predictions_desc" = "Previsões de 12 meses com IA"; "feature_export" = "Exportar dados"; "feature_export_desc" = "Exporte para CSV e JSON"; -"feature_no_ads" = "Sem anúncios"; -"feature_no_ads_desc" = "Experiência sem anúncios para sempre"; "paywall_benefit_history_title" = "Seu histórico completo"; "paywall_benefit_history_subtitle" = "Cada snapshot, contribuição e ganho desde o primeiro dia"; "paywall_benefit_charts_title" = "Gráficos que revelam padrões"; "paywall_benefit_charts_subtitle" = "Alocação, drawdown, desempenho: tudo em um só lugar"; "paywall_benefit_forecasts_title" = "Previsões de 12 meses"; "paywall_benefit_forecasts_subtitle" = "Veja para onde sua carteira provavelmente está indo"; -"paywall_benefit_noads_title" = "Sem anúncios, nunca"; -"paywall_benefit_noads_subtitle" = "Experiência limpa e focada, sem distrações"; "settings_title" = "Ajustes"; "subscription" = "Assinatura"; "notifications" = "Notificações"; diff --git a/PortfolioJournal/Services/AdMobService.swift b/PortfolioJournal/Services/AdMobService.swift deleted file mode 100644 index a4fbb30..0000000 --- a/PortfolioJournal/Services/AdMobService.swift +++ /dev/null @@ -1,215 +0,0 @@ -import Foundation -import SwiftUI -import Combine -import GoogleMobileAds -import UserMessagingPlatform -import AppTrackingTransparency -import AdSupport - -@MainActor -class AdMobService: ObservableObject { - // MARK: - Published Properties - - @Published var isConsentObtained = false - @Published var canShowAds = false - @Published var isLoading = false - @Published var shouldRequestNonPersonalizedAds = false - - // MARK: - Ad Unit IDs - - // Ad Unit IDs - #if DEBUG - static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" // Test ad unit for development - #else - static let bannerAdUnitID = "ca-app-pub-1549720748100858/3342431241" // Production ad unit - #endif - - // MARK: - Initialization - - init() { - setupResetObserver() - Task { - await configureConsentAndRequestAdsIfNeeded() - } - } - - // MARK: - Consent Management - - func requestConsent() async { - await configureConsentAndRequestAdsIfNeeded() - } - - func presentPrivacyOptions() async { - guard let root = topViewController() else { return } - await withCheckedContinuation { continuation in - ConsentForm.presentPrivacyOptionsForm(from: root) { _ in - continuation.resume() - } - } - updateConsentState() - } - - func resetConsent() { - ConsentInformation.shared.reset() - UserDefaults.standard.removeObject(forKey: AppConstants.StorageKeys.adConsentObtained) - isConsentObtained = false - canShowAds = false - shouldRequestNonPersonalizedAds = false - } - - private func setupResetObserver() { - NotificationCenter.default.addObserver( - forName: .didResetData, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - self.resetConsent() - await self.configureConsentAndRequestAdsIfNeeded() - } - } - } - - private func configureConsentAndRequestAdsIfNeeded() async { - isLoading = true - let parameters = RequestParameters() - await withCheckedContinuation { continuation in - ConsentInformation.shared.requestConsentInfoUpdate(with: parameters) { _ in - continuation.resume() - } - } - - if let root = topViewController() { - await withCheckedContinuation { continuation in - ConsentForm.loadAndPresentIfRequired(from: root) { _ in - continuation.resume() - } - } - } - - updateConsentState() - if canShowAds { - await requestTrackingIfNeeded() - updateConsentState() - } - isLoading = false - } - - private func updateConsentState() { - let consentInfo = ConsentInformation.shared - canShowAds = consentInfo.canRequestAds - isConsentObtained = consentInfo.consentStatus == .obtained || consentInfo.consentStatus == .notRequired - - let trackingDenied: Bool - if #available(iOS 14.5, *) { - trackingDenied = ATTrackingManager.trackingAuthorizationStatus != .authorized - } else { - trackingDenied = false - } - - shouldRequestNonPersonalizedAds = !isConsentObtained || trackingDenied - UserDefaults.standard.set(isConsentObtained, forKey: AppConstants.StorageKeys.adConsentObtained) - } - - private func requestTrackingIfNeeded() async { - if #available(iOS 14.5, *) { - _ = await ATTrackingManager.requestTrackingAuthorization() - } - } - - private func topViewController() -> UIViewController? { - guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let root = scene.windows.first?.rootViewController else { - return nil - } - var current = root - while let presented = current.presentedViewController { - current = presented - } - return current - } - - // MARK: - Analytics - - func logAdImpression() { - FirebaseService.shared.logAdImpression(adType: "banner") - } - - func logAdClick() { - FirebaseService.shared.logAdClick(adType: "banner") - } -} - -// MARK: - Banner Ad Coordinator - -class BannerAdCoordinator: NSObject, BannerViewDelegate { - weak var adMobService: AdMobService? - - func bannerViewDidReceiveAd(_ bannerView: BannerView) { - print("Banner ad received") - adMobService?.logAdImpression() - } - - func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) { - print("Banner ad failed to load: \(error.localizedDescription)") - } - - func bannerViewDidRecordImpression(_ bannerView: BannerView) { - // Impression recorded - } - - func bannerViewDidRecordClick(_ bannerView: BannerView) { - adMobService?.logAdClick() - } - - func bannerViewWillPresentScreen(_ bannerView: BannerView) { - // Ad will present full screen - } - - func bannerViewWillDismissScreen(_ bannerView: BannerView) { - // Ad will dismiss - } - - func bannerViewDidDismissScreen(_ bannerView: BannerView) { - // Ad dismissed - } -} - -// MARK: - UIKit Banner View Wrapper - -struct BannerAdView: UIViewRepresentable { - @EnvironmentObject var adMobService: AdMobService - - func makeUIView(context: Context) -> BannerView { - let bannerView = BannerView(adSize: AdSizeBanner) - bannerView.adUnitID = AdMobService.bannerAdUnitID - - // Get root view controller - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootViewController = windowScene.windows.first?.rootViewController { - bannerView.rootViewController = rootViewController - } - - bannerView.delegate = context.coordinator - let request = Request() - if adMobService.shouldRequestNonPersonalizedAds { - let extras = Extras() - extras.additionalParameters = ["npa": "1"] - request.register(extras) - } - bannerView.load(request) - - return bannerView - } - - func updateUIView(_ uiView: BannerView, context: Context) { - // Banner updates automatically - } - - func makeCoordinator() -> BannerAdCoordinator { - let coordinator = BannerAdCoordinator() - coordinator.adMobService = adMobService - return coordinator - } -} diff --git a/PortfolioJournal/Services/ChartShareService.swift b/PortfolioJournal/Services/ChartShareService.swift index c8e49d1..3ca698f 100644 --- a/PortfolioJournal/Services/ChartShareService.swift +++ b/PortfolioJournal/Services/ChartShareService.swift @@ -45,7 +45,6 @@ final class ChartShareService { static let appStoreShareURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318?ct=chart_share")! func share(title: String, subtitle: String, stats: [ChartStat], @ViewBuilder chart: () -> Content) { - FirebaseService.shared.logShare(type: "chart") guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let viewController = windowScene.windows.first?.rootViewController else { return diff --git a/PortfolioJournal/Services/ExportService.swift b/PortfolioJournal/Services/ExportService.swift index d4ebb32..017c106 100644 --- a/PortfolioJournal/Services/ExportService.swift +++ b/PortfolioJournal/Services/ExportService.swift @@ -258,18 +258,9 @@ class ExportService { ) } - viewController.present(activityVC, animated: true) { - FirebaseService.shared.logExportAttempt( - format: format.rawValue, - success: true - ) - } + viewController.present(activityVC, animated: true) } catch { print("Export error: \(error)") - FirebaseService.shared.logExportAttempt( - format: format.rawValue, - success: false - ) } } diff --git a/PortfolioJournal/Services/FirebaseService.swift b/PortfolioJournal/Services/FirebaseService.swift deleted file mode 100644 index 534b1ea..0000000 --- a/PortfolioJournal/Services/FirebaseService.swift +++ /dev/null @@ -1,226 +0,0 @@ -import Foundation -import FirebaseAnalytics -import FirebaseCore - -class FirebaseService { - static let shared = FirebaseService() - - private var isConfigured: Bool { - FirebaseApp.app() != nil - } - - private init() {} - - // MARK: - User Properties - - func setUserTier(_ tier: UserTier) { - guard isConfigured else { return } - Analytics.setUserProperty(tier.rawValue, forName: "user_tier") - } - - enum UserTier: String { - case free = "free" - case premium = "premium" - } - - // MARK: - Screen Tracking - - func logScreenView(screenName: String, screenClass: String? = nil) { - guard isConfigured else { return } - Analytics.logEvent(AnalyticsEventScreenView, parameters: [ - AnalyticsParameterScreenName: screenName, - AnalyticsParameterScreenClass: screenClass ?? screenName - ]) - } - - // MARK: - Investment Events - - func logSourceAdded(categoryName: String, sourceCount: Int) { - guard isConfigured else { return } - Analytics.logEvent("source_added", parameters: [ - "category_name": categoryName, - "total_sources": sourceCount - ]) - } - - func logSourceDeleted(categoryName: String) { - guard isConfigured else { return } - Analytics.logEvent("source_deleted", parameters: [ - "category_name": categoryName - ]) - } - - func logSnapshotAdded(sourceName: String, value: Decimal) { - guard isConfigured else { return } - Analytics.logEvent("snapshot_added", parameters: [ - "source_name": sourceName, - "value": NSDecimalNumber(decimal: value).doubleValue - ]) - } - - func logCategoryCreated(name: String) { - guard isConfigured else { return } - Analytics.logEvent("category_created", parameters: [ - "category_name": name - ]) - } - - // MARK: - Purchase Events - - func logPaywallShown(trigger: String) { - guard isConfigured else { return } - Analytics.logEvent("paywall_shown", parameters: [ - "paywall_trigger": trigger - ]) - } - - func logPurchaseAttempt(productId: String) { - guard isConfigured else { return } - Analytics.logEvent("purchase_attempt", parameters: [ - "product_id": productId - ]) - } - - func logPurchaseSuccess(productId: String, price: Decimal, isFamilyShared: Bool) { - guard isConfigured else { return } - Analytics.logEvent(AnalyticsEventPurchase, parameters: [ - AnalyticsParameterItemID: productId, - AnalyticsParameterPrice: NSDecimalNumber(decimal: price).doubleValue, - AnalyticsParameterCurrency: "EUR", - "is_family_shared": isFamilyShared - ]) - } - - func logPurchaseFailure(productId: String, error: String) { - guard isConfigured else { return } - Analytics.logEvent("purchase_failed", parameters: [ - "product_id": productId, - "error": error - ]) - } - - func logRestorePurchases(success: Bool) { - guard isConfigured else { return } - Analytics.logEvent("restore_purchases", parameters: [ - "success": success - ]) - } - - // MARK: - Feature Usage Events - - func logChartViewed(chartType: String, isPremium: Bool) { - guard isConfigured else { return } - Analytics.logEvent("chart_viewed", parameters: [ - "chart_type": chartType, - "is_premium_chart": isPremium - ]) - } - - func logPredictionViewed(algorithm: String) { - guard isConfigured else { return } - Analytics.logEvent("prediction_viewed", parameters: [ - "algorithm": algorithm - ]) - } - - func logExportAttempt(format: String, success: Bool) { - guard isConfigured else { return } - Analytics.logEvent("export_attempt", parameters: [ - "format": format, - "success": success - ]) - } - - func logNotificationScheduled(frequency: String) { - guard isConfigured else { return } - Analytics.logEvent("notification_scheduled", parameters: [ - "frequency": frequency - ]) - } - - // MARK: - Ad Events - - func logAdImpression(adType: String) { - guard isConfigured else { return } - Analytics.logEvent("ad_impression", parameters: [ - "ad_type": adType - ]) - } - - func logAdClick(adType: String) { - guard isConfigured else { return } - Analytics.logEvent("ad_click", parameters: [ - "ad_type": adType - ]) - } - - // MARK: - Engagement Events - - func logOnboardingCompleted(stepCount: Int) { - guard isConfigured else { return } - Analytics.logEvent("onboarding_completed", parameters: [ - "steps_completed": stepCount - ]) - } - - /// Per-step onboarding funnel event — lets us see exactly where users drop off - /// inside onboarding (only the final `onboarding_completed` existed before). - func logOnboardingStep(step: Int) { - guard isConfigured else { return } - Analytics.logEvent("onboarding_step", parameters: [ - "step": step - ]) - } - - func logOnboardingSkipped(atStep step: Int) { - guard isConfigured else { return } - Analytics.logEvent("onboarding_skipped", parameters: [ - "step": step - ]) - } - - /// Viral loop measurement: fired whenever the user opens a share sheet with - /// app content (portfolio image, check-in, goal). `type` identifies the surface. - func logShare(type: String) { - guard isConfigured else { return } - Analytics.logEvent("content_shared", parameters: [ - "share_type": type - ]) - } - - func logWidgetUsed(widgetType: String) { - guard isConfigured else { return } - Analytics.logEvent("widget_used", parameters: [ - "widget_type": widgetType - ]) - } - - func logAppOpened(fromWidget: Bool) { - guard isConfigured else { return } - Analytics.logEvent("app_opened", parameters: [ - "from_widget": fromWidget - ]) - } - - // MARK: - Portfolio Events - - func logPortfolioMilestone(totalValue: Decimal, milestone: String) { - guard isConfigured else { return } - Analytics.logEvent("portfolio_milestone", parameters: [ - "total_value": NSDecimalNumber(decimal: totalValue).doubleValue, - "milestone": milestone - ]) - } - - // MARK: - Error Events - - func logError(type: String, message: String, context: String? = nil) { - guard isConfigured else { return } - Analytics.logEvent("app_error", parameters: [ - "error_type": type, - "error_message": message, - "context": context ?? "" - ]) - } -} - diff --git a/PortfolioJournal/Services/GoalShareService.swift b/PortfolioJournal/Services/GoalShareService.swift index 311a436..fd7374a 100644 --- a/PortfolioJournal/Services/GoalShareService.swift +++ b/PortfolioJournal/Services/GoalShareService.swift @@ -60,7 +60,6 @@ class GoalShareService { estimatedCompletionDate: Date? = nil, privacyMode: Bool = false ) { - FirebaseService.shared.logShare(type: "goal") guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let viewController = windowScene.windows.first?.rootViewController else { return diff --git a/PortfolioJournal/Services/IAPService.swift b/PortfolioJournal/Services/IAPService.swift index 2a7589d..ce5138b 100644 --- a/PortfolioJournal/Services/IAPService.swift +++ b/PortfolioJournal/Services/IAPService.swift @@ -107,13 +107,6 @@ class IAPService: ObservableObject { purchaseState = .purchased - // Track analytics - FirebaseService.shared.logPurchaseSuccess( - productId: product.id, - price: product.price, - isFamilyShared: isFamilyShared - ) - case .userCancelled: purchaseState = .idle @@ -125,10 +118,6 @@ class IAPService: ObservableObject { } } catch { purchaseState = .failed(error.localizedDescription) - FirebaseService.shared.logPurchaseFailure( - productId: product.id, - error: error.localizedDescription - ) throw error } } @@ -312,7 +301,6 @@ extension IAPService { ("chart.bar.xaxis", "Advanced Charts", "5 types of detailed analytics charts"), ("wand.and.stars", "Predictions", "AI-powered 12-month forecasts"), ("square.and.arrow.up", "Export Data", "Export to CSV and JSON formats"), - ("xmark.circle", "No Ads", "Ad-free experience forever"), ("person.2", "Family Sharing", "Share with up to 5 family members") ] @@ -333,9 +321,6 @@ extension IAPService { String(localized: "paywall_benefit_accounts_subtitle")), ("person.3", String(localized: "paywall_benefit_family_title"), - String(localized: "paywall_benefit_family_subtitle")), - ("xmark.circle", - String(localized: "paywall_benefit_noads_title"), - String(localized: "paywall_benefit_noads_subtitle")) + String(localized: "paywall_benefit_family_subtitle")) ]} } diff --git a/PortfolioJournal/Services/NotificationService.swift b/PortfolioJournal/Services/NotificationService.swift index 8e74684..b1b081b 100644 --- a/PortfolioJournal/Services/NotificationService.swift +++ b/PortfolioJournal/Services/NotificationService.swift @@ -95,7 +95,6 @@ class NotificationService: ObservableObject { print("Failed to schedule notification: \(error)") } else { print("Scheduled reminder for \(source.name) on \(triggerDate)") - FirebaseService.shared.logNotificationScheduled(frequency: source.notificationFrequency) } } } diff --git a/PortfolioJournal/Services/ShareService.swift b/PortfolioJournal/Services/ShareService.swift index 832b422..6297538 100644 --- a/PortfolioJournal/Services/ShareService.swift +++ b/PortfolioJournal/Services/ShareService.swift @@ -100,7 +100,6 @@ class ShareService { @MainActor func shareMonthlyCheckIn(summary: MonthlySummary, appName: String) { - FirebaseService.shared.logShare(type: "monthly_checkin") let text = Self.buildMonthlyCheckInShareText(summary: summary, appName: appName) shareCard( cardTitle: "\(summary.periodLabel) Check-in", @@ -127,7 +126,6 @@ class ShareService { @MainActor func shareMonthlySummary(_ data: MonthlySummaryShareData) { - FirebaseService.shared.logShare(type: "monthly_summary") let appName = Self.appDisplayName var lines = [ @@ -178,7 +176,6 @@ class ShareService { percentOnly: Bool = false, storyFormat: Bool = false ) { - FirebaseService.shared.logShare(type: percentOnly ? "portfolio_value_percent" : "portfolio_value") let appName = Self.appDisplayName let display = Self.portfolioCardStrings( totalValue: totalValue, diff --git a/PortfolioJournal/Utilities/Constants/AppConstants.swift b/PortfolioJournal/Utilities/Constants/AppConstants.swift index 7eb1478..d093f37 100644 --- a/PortfolioJournal/Utilities/Constants/AppConstants.swift +++ b/PortfolioJournal/Utilities/Constants/AppConstants.swift @@ -17,16 +17,6 @@ enum AppConstants { static let premiumProductID = "com.portfoliojournal.premium" - // MARK: - AdMob - - #if DEBUG - static let adMobAppID = "ca-app-pub-3940256099942544~1458002511" // Test App ID - static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" // Test Banner - #else - static let adMobAppID = "ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY" // Replace with real App ID - static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with real Ad Unit ID - #endif - // MARK: - Currency static let defaultCurrency = "EUR" @@ -52,7 +42,6 @@ enum AppConstants { static let smallIconSize: CGFloat = 16 static let largeIconSize: CGFloat = 32 - static let bannerAdHeight: CGFloat = 50 static let tabBarHeight: CGFloat = 49 static let cardShadowRadius: CGFloat = 4 @@ -88,7 +77,6 @@ enum AppConstants { enum StorageKeys { static let onboardingCompleted = "onboardingCompleted" - static let adConsentObtained = "adConsentObtained" static let lastSyncDate = "lastSyncDate" static let selectedCategoryFilter = "selectedCategoryFilter" static let preferredChartType = "preferredChartType" @@ -119,7 +107,6 @@ enum AppConstants { static let enableExport = true static let enableWidgets = true static let enableNotifications = true - static let enableAnalytics = true } } diff --git a/PortfolioJournal/Utilities/FreemiumValidator.swift b/PortfolioJournal/Utilities/FreemiumValidator.swift index a3dc0a8..b9a36fe 100644 --- a/PortfolioJournal/Utilities/FreemiumValidator.swift +++ b/PortfolioJournal/Utilities/FreemiumValidator.swift @@ -128,8 +128,6 @@ class FreemiumValidator: ObservableObject { return false case .export: return false - case .noAds: - return false } } @@ -142,7 +140,6 @@ class FreemiumValidator: ObservableObject { case advancedCharts = "advanced_charts" case predictions = "predictions" case export = "export" - case noAds = "no_ads" var id: String { rawValue } @@ -154,7 +151,6 @@ class FreemiumValidator: ObservableObject { case .advancedCharts: return "Advanced Charts" case .predictions: return "Predictions" case .export: return "Export Data" - case .noAds: return "No Ads" } } @@ -166,7 +162,6 @@ class FreemiumValidator: ObservableObject { case .advancedCharts: return "chart.bar.xaxis" case .predictions: return "wand.and.stars" case .export: return "square.and.arrow.up" - case .noAds: return "xmark.circle" } } @@ -184,8 +179,6 @@ class FreemiumValidator: ObservableObject { return "AI-powered 12-month forecasts" case .export: return "Export to CSV and JSON formats" - case .noAds: - return "Ad-free experience forever" } } } diff --git a/PortfolioJournal/ViewModels/ChartsViewModel.swift b/PortfolioJournal/ViewModels/ChartsViewModel.swift index 96bf0be..c37687f 100644 --- a/PortfolioJournal/ViewModels/ChartsViewModel.swift +++ b/PortfolioJournal/ViewModels/ChartsViewModel.swift @@ -405,8 +405,6 @@ class ChartsViewModel: ObservableObject { category: selectedCategory, timeRange: selectedTimeRange ) - - FirebaseService.shared.logScreenView(screenName: "Charts") } /// Selection is NEVER blocked — premium charts select normally and the chart @@ -423,10 +421,6 @@ class ChartsViewModel: ObservableObject { // observer: makes selection deterministic and lets re-tapping a tile // act as a retry if a previous update was dropped. updateChartData(chartType: chartType, category: selectedCategory, timeRange: selectedTimeRange) - FirebaseService.shared.logChartViewed( - chartType: chartType.rawValue, - isPremium: chartType.isPremium - ) } func availableTimeRanges(for chartType: ChartType) -> [TimeRange] { diff --git a/PortfolioJournal/ViewModels/SettingsViewModel.swift b/PortfolioJournal/ViewModels/SettingsViewModel.swift index a563b7f..a7c5584 100644 --- a/PortfolioJournal/ViewModels/SettingsViewModel.swift +++ b/PortfolioJournal/ViewModels/SettingsViewModel.swift @@ -11,7 +11,6 @@ class SettingsViewModel: ObservableObject { @Published var isFamilyShared = false @Published var notificationsEnabled = false @Published var defaultNotificationTime = Date() - @Published var analyticsEnabled = true @Published var currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency @Published var inputMode: InputMode = .simple @@ -93,7 +92,6 @@ class SettingsViewModel: ObservableObject { let settings = AppSettings.getOrCreate(in: context) defaultNotificationTime = settings.defaultNotificationTime ?? Date() - analyticsEnabled = settings.enableAnalytics currencyCode = settings.currency inputMode = InputMode(rawValue: settings.inputMode) ?? .simple backupRetentionCount = loadBackupRetention() @@ -107,8 +105,6 @@ class SettingsViewModel: ObservableObject { // Load statistics directly from database to avoid async race conditions loadStatistics() - - FirebaseService.shared.logScreenView(screenName: "Settings") } private func loadStatistics() { @@ -131,7 +127,6 @@ class SettingsViewModel: ObservableObject { func upgradeToPremium() { showingPaywall = true - FirebaseService.shared.logPaywallShown(trigger: "settings_upgrade") } func restorePurchases() async { @@ -141,10 +136,8 @@ class SettingsViewModel: ObservableObject { if isPremium { successMessage = "Purchases restored successfully!" - FirebaseService.shared.logRestorePurchases(success: true) } else { errorMessage = "No purchases to restore" - FirebaseService.shared.logRestorePurchases(success: false) } } @@ -191,7 +184,6 @@ class SettingsViewModel: ObservableObject { func exportData(format: ExportService.ExportFormat) { guard freemiumValidator.canExport() else { showingPaywall = true - FirebaseService.shared.logPaywallShown(trigger: "export") return } @@ -242,13 +234,11 @@ class SettingsViewModel: ObservableObject { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.shareItem = ShareItem(url: tempURL) } - FirebaseService.shared.logExportAttempt(format: format.rawValue, success: true) } } catch { await MainActor.run { isExporting = false errorMessage = "Export failed: \(error.localizedDescription)" - FirebaseService.shared.logExportAttempt(format: format.rawValue, success: false) } } } @@ -272,19 +262,6 @@ class SettingsViewModel: ObservableObject { freemiumValidator.canExport() } - // MARK: - Analytics - - func toggleAnalytics(_ enabled: Bool) { - analyticsEnabled = enabled - - let context = CoreDataStack.shared.viewContext - let settings = AppSettings.getOrCreate(in: context) - settings.enableAnalytics = enabled - CoreDataStack.shared.save() - - // Note: In production, you'd also update Firebase Analytics consent - } - // MARK: - Currency func updateCurrency(_ code: String) { @@ -555,7 +532,6 @@ class SettingsViewModel: ObservableObject { guard isPremium else { backupsEnabled = false UserDefaults.standard.set(false, forKey: backupsEnabledKey) - FirebaseService.shared.logPaywallShown(trigger: "backups") showingPaywall = true return } diff --git a/PortfolioJournal/ViewModels/SourceDetailViewModel.swift b/PortfolioJournal/ViewModels/SourceDetailViewModel.swift index b1797e3..d53a655 100644 --- a/PortfolioJournal/ViewModels/SourceDetailViewModel.swift +++ b/PortfolioJournal/ViewModels/SourceDetailViewModel.swift @@ -87,8 +87,6 @@ class SourceDetailViewModel: ObservableObject { isLoading = true refreshData() isLoading = false - - FirebaseService.shared.logScreenView(screenName: "SourceDetail") } func refreshData() { @@ -167,9 +165,6 @@ class SourceDetailViewModel: ObservableObject { // Reschedule notification NotificationService.shared.scheduleReminder(for: source) - // Log analytics - FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value) - showingAddSnapshot = false refreshData() } @@ -216,9 +211,6 @@ class SourceDetailViewModel: ObservableObject { // Cancel any pending notifications for this source NotificationService.shared.cancelReminder(for: source) - // Log analytics before deletion - FirebaseService.shared.logSourceDeleted(categoryName: source.category?.name ?? "Uncategorized") - // Delete the source using the existing repository sourceRepository.deleteSource(source) } @@ -226,14 +218,9 @@ class SourceDetailViewModel: ObservableObject { // MARK: - Predictions func showPredictions() { - if freemiumValidator.canViewPredictions() { - // Already loaded, just navigate - FirebaseService.shared.logPredictionViewed( - algorithm: predictionResult?.algorithm.rawValue ?? "unknown" - ) - } else { + // Predictions are already loaded; free users get the paywall instead. + if !freemiumValidator.canViewPredictions() { showingPaywall = true - FirebaseService.shared.logPaywallShown(trigger: "predictions") } } diff --git a/PortfolioJournal/ViewModels/SourceListViewModel.swift b/PortfolioJournal/ViewModels/SourceListViewModel.swift index 432473f..c666811 100644 --- a/PortfolioJournal/ViewModels/SourceListViewModel.swift +++ b/PortfolioJournal/ViewModels/SourceListViewModel.swift @@ -76,8 +76,6 @@ class SourceListViewModel: ObservableObject { sourceRepository.fetchSources() categoryRepository.fetchCategories() isLoading = false - - FirebaseService.shared.logScreenView(screenName: "SourceList") } private func filterAndSortSources(_ allSources: [InvestmentSource]) { @@ -115,7 +113,6 @@ class SourceListViewModel: ObservableObject { showingAddSource = true } else { showingPaywall = true - FirebaseService.shared.logPaywallShown(trigger: "source_limit") } } @@ -137,26 +134,15 @@ class SourceListViewModel: ObservableObject { // Schedule notification NotificationService.shared.scheduleReminder(for: source) - // Log analytics - FirebaseService.shared.logSourceAdded( - categoryName: category.name, - sourceCount: sourceRepository.sourceCount - ) - showingAddSource = false } func deleteSource(_ source: InvestmentSource) { - let categoryName = source.category?.name ?? "Unknown" - // Cancel notifications NotificationService.shared.cancelReminder(for: source) // Delete source sourceRepository.deleteSource(source) - - // Log analytics - FirebaseService.shared.logSourceDeleted(categoryName: categoryName) } func deleteSource(at offsets: IndexSet) { diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index 46542d8..c735e34 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -96,9 +96,6 @@ struct ChartsContainerView: View { if !viewModel.isPremium { CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall) - .onAppear { - FirebaseService.shared.logPaywallShown(trigger: "charts_banner") - } } chartContent if viewModel.hiddenHistoryMonths > 0 { @@ -581,7 +578,6 @@ struct ChartsContainerView: View { /// 12-month window; tell them exactly how much is locked instead of hiding it. private var lockedHistoryTeaser: some View { Button { - FirebaseService.shared.logPaywallShown(trigger: "history_teaser") viewModel.showingPaywall = true } label: { HStack(spacing: 8) { @@ -611,9 +607,6 @@ struct ChartsContainerView: View { chartTypeChipBar if !viewModel.isPremium { CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall) - .onAppear { - FirebaseService.shared.logPaywallShown(trigger: "charts_banner") - } } iPhonePeriodControl if hasActiveFilters { @@ -862,7 +855,6 @@ struct ChartsContainerView: View { /// back. private func drillDown(to name: String) { guard viewModel.isPremium else { - FirebaseService.shared.logPaywallShown(trigger: "chart_workable") viewModel.showingPaywall = true return } @@ -916,7 +908,6 @@ struct ChartsContainerView: View { contributions: viewModel.contributionsData, isPremium: viewModel.isPremium, onPremiumNeeded: { - FirebaseService.shared.logPaywallShown(trigger: "chart_workable") viewModel.showingPaywall = true } ) @@ -996,7 +987,6 @@ struct ChartsContainerView: View { .multilineTextAlignment(.center) Button { - FirebaseService.shared.logPaywallShown(trigger: "advanced_charts") viewModel.showingPaywall = true } label: { Text(String(localized: "chart_locked_cta")) diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift index 2a3409a..7c548f5 100644 --- a/PortfolioJournal/Views/Dashboard/DashboardView.swift +++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift @@ -113,9 +113,6 @@ struct DashboardView: View { goalsViewModel.selectedAccount = accountStore.selectedAccount goalsViewModel.showAllAccounts = accountStore.showAllAccounts goalsViewModel.refresh() - // Screen view logged here (once per appearance) — NOT inside - // refreshData(), which re-runs on every Core Data change. - FirebaseService.shared.logScreenView(screenName: "Dashboard") } // Performance: Combine account selection changes into a single handler .onChange(of: accountStore.selectedAccount) { _, newAccount in diff --git a/PortfolioJournal/Views/Dashboard/EvolutionChart.swift b/PortfolioJournal/Views/Dashboard/EvolutionChart.swift index d52d3a9..7ea6821 100644 --- a/PortfolioJournal/Views/Dashboard/EvolutionChart.swift +++ b/PortfolioJournal/Views/Dashboard/EvolutionChart.swift @@ -218,7 +218,6 @@ struct EvolutionChartCard: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) Button { - FirebaseService.shared.logPaywallShown(trigger: "home_charts") onRequestUpgrade() } label: { Label(String(localized: "chart_locked_cta"), systemImage: "lock.open.fill") diff --git a/PortfolioJournal/Views/Onboarding/OnboardingView.swift b/PortfolioJournal/Views/Onboarding/OnboardingView.swift index abdcdd9..609c009 100644 --- a/PortfolioJournal/Views/Onboarding/OnboardingView.swift +++ b/PortfolioJournal/Views/Onboarding/OnboardingView.swift @@ -96,7 +96,6 @@ struct OnboardingView: View { } Button { - FirebaseService.shared.logOnboardingSkipped(atStep: currentPage) completeOnboarding() } label: { Text("Skip") @@ -124,9 +123,6 @@ struct OnboardingView: View { .onAppear { selectedCurrency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency } - .onChange(of: currentPage) { _, newPage in - FirebaseService.shared.logOnboardingStep(step: newPage) - } .sheet(isPresented: $showingImportSheet) { ImportDataView(importContext: .onboarding) .environmentObject(iapService) @@ -157,9 +153,6 @@ struct OnboardingView: View { SampleDataService.shared.seedSampleData() } - // Log analytics - FirebaseService.shared.logOnboardingCompleted(stepCount: pages.count + 1) - // Request notification permission Task { await NotificationService.shared.requestAuthorization() diff --git a/PortfolioJournal/Views/Premium/PaywallView.swift b/PortfolioJournal/Views/Premium/PaywallView.swift index e347e4a..64646db 100644 --- a/PortfolioJournal/Views/Premium/PaywallView.swift +++ b/PortfolioJournal/Views/Premium/PaywallView.swift @@ -336,9 +336,6 @@ struct PaywallView: View { private func purchase() { isPurchasing = true let product = (selectedPlan == .annual ? iapService.annualProduct : iapService.premiumProduct) - FirebaseService.shared.logPurchaseAttempt( - productId: product?.id ?? IAPService.premiumProductID - ) Task { do { diff --git a/PortfolioJournal/Views/Settings/SettingsView.swift b/PortfolioJournal/Views/Settings/SettingsView.swift index 639f9a9..5de83f8 100644 --- a/PortfolioJournal/Views/Settings/SettingsView.swift +++ b/PortfolioJournal/Views/Settings/SettingsView.swift @@ -5,7 +5,6 @@ import UIKit struct SettingsView: View { private let iapService: IAPService @StateObject private var viewModel: SettingsViewModel - @EnvironmentObject private var adMobService: AdMobService @EnvironmentObject private var accountStore: AccountStore @EnvironmentObject private var tabSelection: TabSelectionStore @AppStorage("showForecast") private var showForecast = true @@ -776,14 +775,6 @@ struct SettingsView: View { .onChange(of: viewModel.inputMode) { _, newValue in viewModel.updateInputMode(newValue) } - - if !viewModel.isPremium { - Button("Manage Ad Consent") { - Task { - await adMobService.presentPrivacyOptions() - } - } - } } header: { Text("Preferences") } footer: { diff --git a/PortfolioJournal/Views/Sources/AddSourceView.swift b/PortfolioJournal/Views/Sources/AddSourceView.swift index 8309329..108e2d1 100644 --- a/PortfolioJournal/Views/Sources/AddSourceView.swift +++ b/PortfolioJournal/Views/Sources/AddSourceView.swift @@ -183,12 +183,6 @@ struct AddSourceView: View { date: Date(), value: value ) - // Instrument like AddSnapshotView does — without this, snapshots - // created via the initial value never count in the activation funnel. - FirebaseService.shared.logSnapshotAdded( - sourceName: name.trimmingCharacters(in: .whitespaces), - value: value - ) hasInitialSnapshot = true } else { hasInitialSnapshot = false @@ -203,12 +197,6 @@ struct AddSourceView: View { let total = allSources.reduce(Decimal.zero) { $0 + $1.latestValue } NotificationService.shared.checkAndScheduleMilestoneNotification(portfolioValue: total) - // Log analytics - FirebaseService.shared.logSourceAdded( - categoryName: category.name, - sourceCount: repository.sourceCount - ) - if hasInitialSnapshot { dismiss() } else { @@ -641,9 +629,6 @@ struct AddSnapshotView: View { // Reschedule notification NotificationService.shared.scheduleReminder(for: source) - // Log analytics - FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value) - // When editing, offer to propagate a changed contribution to other snapshots. if isEdit, let contribution = viewModel.contribution, viewModel.contributionChanged { pendingContribution = contribution diff --git a/Scripts/analyze_crashlytics.py b/Scripts/analyze_crashlytics.py deleted file mode 100644 index 90c3e3f..0000000 --- a/Scripts/analyze_crashlytics.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -""" -Crashlytics report for Portfolio Journal via the BigQuery export. - -Setup (one-time, already done 2026-07-13): - - Firebase console → project portfoliojournal-ef2d7 → Integrations → BigQuery: - enable the Crashlytics export (requires Blaze plan). - - Grant the service account sirius@sirius-vazquez.iam BigQuery Data Viewer + - Job User on project portfoliojournal-ef2d7. - -Creds come from `pass show google/sirius-service-account`. The export table is -created on the first crash after enabling, named like -`firebase_crashlytics.com_alexandrevazquez_PortfolioJournal_IOS`. - -Usage: python3 Scripts/analyze_crashlytics.py [days] # default 30 -""" -import subprocess, json, sys, urllib.request, urllib.error - -PROJECT = "portfoliojournal-ef2d7" -DATASET = "firebase_crashlytics" -DAYS = int(sys.argv[1]) if len(sys.argv) > 1 else 30 - - -def creds_token(): - raw = subprocess.run(["pass", "show", "google/sirius-service-account"], - capture_output=True, text=True).stdout - try: - info = json.loads(raw) - except Exception: - out, ins, esc = [], False, False - for c in raw: - if esc: out.append(c); esc = False - elif c == "\\": out.append(c); esc = True - elif c == '"': ins = not ins; out.append(c) - elif ins and c == "\n": out.append("\\n") - elif ins and c == "\r": pass - else: out.append(c) - info = json.loads("".join(out)) - from google.oauth2 import service_account - import google.auth.transport.requests - c = service_account.Credentials.from_service_account_info( - info, scopes=["https://www.googleapis.com/auth/cloud-platform"]) - c.refresh(google.auth.transport.requests.Request()) - return c.token - - -def api(token, path): - req = urllib.request.Request( - f"https://bigquery.googleapis.com/bigquery/v2/projects/{PROJECT}{path}", - headers={"Authorization": f"Bearer {token}"}) - return json.load(urllib.request.urlopen(req)) - - -def query(token, sql): - body = json.dumps({"query": sql, "useLegacySql": False, "timeoutMs": 60000}).encode() - req = urllib.request.Request( - f"https://bigquery.googleapis.com/bigquery/v2/projects/{PROJECT}/queries", - data=body, headers={"Authorization": f"Bearer {token}", - "Content-Type": "application/json"}) - d = json.load(urllib.request.urlopen(req)) - cols = [f["name"] for f in d.get("schema", {}).get("fields", [])] - rows = [[c.get("v") for c in r["f"]] for r in d.get("rows", [])] - return cols, rows - - -def main(): - token = creds_token() - try: - td = api(token, f"/datasets/{DATASET}/tables?maxResults=100") - except urllib.error.HTTPError as e: - print(f"No access to {DATASET} (HTTP {e.code}).") - return - tables = [t["tableReference"]["tableId"] for t in td.get("tables", [])] - crash_tables = [t for t in tables if "IOS" in t or "ios" in t.lower()] - if not crash_tables: - print("No Crashlytics export tables yet — no crashes exported since the " - "export was enabled. (Good news.)") - return - table = crash_tables[0] - print(f"== Crashlytics {table} — últimos {DAYS}d ==\n") - - sql = f""" - SELECT - issue_title, issue_subtitle, - application.display_version AS version, - COUNT(*) AS events, - COUNT(DISTINCT installation_uuid) AS users, - COUNTIF(error_type = 'FATAL') AS fatal - FROM `{PROJECT}.{DATASET}.{table}` - WHERE event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {DAYS} DAY) - GROUP BY issue_title, issue_subtitle, version - ORDER BY events DESC - LIMIT 25 - """ - cols, rows = query(token, sql) - if not rows: - print("Sin crashes en el periodo.") - return - for r in rows: - title, sub, ver, events, users, fatal = r - print(f"[{ver}] {title} — {sub}") - print(f" {events} eventos · {users} usuarios · {fatal} fatales\n") - - -if __name__ == "__main__": - main() diff --git a/Scripts/analyze_ga4.py b/Scripts/analyze_ga4.py deleted file mode 100644 index 9ce9d24..0000000 --- a/Scripts/analyze_ga4.py +++ /dev/null @@ -1,185 +0,0 @@ -""" -GA4 Analytics report for Portfolio Journal -Property: 300195945 -""" - -import os -from datetime import date, timedelta -from google.analytics.data_v1beta import BetaAnalyticsDataClient -from google.analytics.data_v1beta.types import ( - RunReportRequest, Dimension, Metric, DateRange, OrderBy, FilterExpression, Filter -) - -PROPERTY_ID = "519342587" - -import subprocess, json, tempfile - -def _fix_multiline_json(raw): - result, in_string, escape_next = [], False, False - for c in raw: - if escape_next: - result.append(c); escape_next = False - elif c == '\\': - result.append(c); escape_next = True - elif c == '"': - in_string = not in_string; result.append(c) - elif in_string and c == '\n': - result.append('\\n') - elif in_string and c == '\r': - pass - else: - result.append(c) - return ''.join(result) - -key_raw = subprocess.check_output(["pass", "show", "google/sirius-service-account"]).decode() -key_json = json.dumps(json.loads(_fix_multiline_json(key_raw))) -_tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) -_tmp.write(key_json); _tmp.flush() -os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = _tmp.name -client = BetaAnalyticsDataClient() - -TODAY = date.today().isoformat() -D7 = (date.today() - timedelta(days=7)).isoformat() -D28 = (date.today() - timedelta(days=28)).isoformat() -D90 = (date.today() - timedelta(days=90)).isoformat() - -def run(dimensions, metrics, date_from, date_to=TODAY, order_by=None, limit=20): - req = RunReportRequest( - property=f"properties/{PROPERTY_ID}", - dimensions=[Dimension(name=d) for d in dimensions], - metrics=[Metric(name=m) for m in metrics], - date_ranges=[DateRange(start_date=date_from, end_date=date_to)], - limit=limit, - ) - if order_by: - req.order_bys = order_by - return client.run_report(req) - -def run_no_dim(metrics, date_from, date_to=TODAY): - req = RunReportRequest( - property=f"properties/{PROPERTY_ID}", - metrics=[Metric(name=m) for m in metrics], - date_ranges=[DateRange(start_date=date_from, end_date=date_to)], - ) - return client.run_report(req) - -def header(title): - print(f"\n{'='*60}") - print(f" {title}") - print('='*60) - -def fmt_row(row, widths): - parts = [str(v.value).ljust(w) for v, w in zip(list(row.dimension_values) + list(row.metric_values), widths)] - print(" " + " ".join(parts)) - -# ── 1. Overview ──────────────────────────────────────────────── -header("OVERVIEW — últimos 28 días vs 7 días") - -r28 = run_no_dim(["activeUsers", "sessions", "screenPageViews", "averageSessionDuration"], D28) -r7 = run_no_dim(["activeUsers", "sessions", "screenPageViews", "averageSessionDuration"], D7) - -def ovw(r): - row = r.rows[0] - vals = [v.value for v in row.metric_values] - return vals - -v28 = ovw(r28) -v7 = ovw(r7) -labels = ["Usuarios activos", "Sesiones", "Pantallas vistas", "Duración media (s)"] -print(f"\n {'Métrica':<30} {'28d':>10} {'7d':>10}") -print(" " + "-"*52) -for l, a, b in zip(labels, v28, v7): - print(f" {l:<30} {a:>10} {b:>10}") - -# ── 2. Pantallas más visitadas ────────────────────────────────── -header("PANTALLAS MÁS VISITADAS — últimos 28d") -r = run(["unifiedScreenName"], ["screenPageViews", "activeUsers"], D28, - order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="screenPageViews"), desc=True)]) -print(f"\n {'Pantalla':<40} {'Vistas':>8} {'Usuarios':>10}") -print(" " + "-"*60) -for row in r.rows: - fmt_row(row, [40, 8, 10]) - -# ── 3. Eventos clave ──────────────────────────────────────────── -header("EVENTOS CLAVE — últimos 28d") -r = run(["eventName"], ["eventCount", "totalUsers"], D28, - order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="eventCount"), desc=True)], - limit=30) -print(f"\n {'Evento':<45} {'Count':>8} {'Usuarios':>10}") -print(" " + "-"*65) -for row in r.rows: - fmt_row(row, [45, 8, 10]) - -# ── 4. Funnel paywall ─────────────────────────────────────────── -header("FUNNEL PAYWALL — últimos 90d") -paywall_events = [ - "paywall_shown", "purchase_attempt", "purchase_success", "purchase_failure", - "restore_purchases" -] -r = run(["eventName"], ["eventCount", "totalUsers"], D90, - order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="eventCount"), desc=True)], - limit=50) -print(f"\n {'Evento paywall':<40} {'Count':>8} {'Usuarios':>10}") -print(" " + "-"*60) -for row in r.rows: - name = row.dimension_values[0].value - if any(p in name for p in ["paywall", "purchase", "restore", "premium"]): - fmt_row(row, [40, 8, 10]) - -# ── 5. Triggers del paywall ───────────────────────────────────── -header("TRIGGERS DEL PAYWALL — últimos 90d (qué feature lo dispara)") -try: - r = run(["eventName", "customEvent:paywall_trigger"], ["eventCount"], D90, - order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="eventCount"), desc=True)], - limit=30) - rows = [row for row in r.rows if "paywall" in row.dimension_values[0].value] - if rows: - print(f"\n {'Evento':<30} {'Trigger':<25} {'Count':>8}") - print(" " + "-"*65) - for row in rows: - fmt_row(row, [30, 25, 8]) - else: - print("\n (sin eventos paywall con trigger en este periodo — los datos aparecerán desde hoy)") -except Exception as e: - print(f"\n Error: {e}") - -# ── 6. Retención — nuevos vs recurrentes ─────────────────────── -header("NUEVOS vs RECURRENTES — últimos 28d") -r = run(["newVsReturning"], ["activeUsers", "sessions", "screenPageViews"], D28, - order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)]) -print(f"\n {'Tipo':<20} {'Usuarios':>10} {'Sesiones':>10} {'Pantallas':>10}") -print(" " + "-"*54) -for row in r.rows: - fmt_row(row, [20, 10, 10, 10]) - -# ── 7. Versiones de app ───────────────────────────────────────── -header("VERSIONES EN USO — últimos 28d") -r = run(["appVersion"], ["activeUsers", "sessions"], D28, - order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)], - limit=10) -print(f"\n {'Versión':<20} {'Usuarios':>10} {'Sesiones':>10}") -print(" " + "-"*44) -for row in r.rows: - fmt_row(row, [20, 10, 10]) - -# ── 8. Dispositivos ───────────────────────────────────────────── -header("DISPOSITIVOS — últimos 28d") -r = run(["deviceModel"], ["activeUsers"], D28, - order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)], - limit=10) -print(f"\n {'Dispositivo':<30} {'Usuarios':>10}") -print(" " + "-"*44) -for row in r.rows: - fmt_row(row, [30, 10]) - -# ── 9. País / región ──────────────────────────────────────────── -header("PAÍSES — últimos 28d") -r = run(["country"], ["activeUsers"], D28, - order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)], - limit=10) -print(f"\n {'País':<30} {'Usuarios':>10}") -print(" " + "-"*44) -for row in r.rows: - fmt_row(row, [30, 10]) - -print("\n")