quitar AdMob, Firebase Analytics y ATT de la app
"Portfolio Journal sin AdMob ni Google: es la única forma de que 'sin rastreo' sea verdad, y el ingreso por anuncios es despreciable" — la ficha de App Store prometía "sin analíticas, sin rastreo, sin venta de datos" mientras la app enlazaba GoogleMobileAds y FirebaseAnalytics, pedía permiso de rastreo al arrancar y mandaba el importe del saldo a GA4 en el evento snapshot_added. SDKs fuera del proyecto (project.pbxproj): paquetes firebase-ios-sdk y swift-package-manager-google-mobile-ads, productos FirebaseCore, FirebaseAnalytics, GoogleMobileAds y FirebaseCrashlytics, más la fase de build "Upload dSYMs to Crashlytics". Package.resolved se queda sin nada que resolver. Con la fase de script fuera, ENABLE_USER_SCRIPT_SANDBOXING vuelve a YES en el target app (estaba en NO solo por Crashlytics). Código borrado: - Services/AdMobService.swift entero — con él se van el flujo UMP, BannerAdView, BannerAdCoordinator y la llamada a ATTrackingManager.requestTrackingAuthorization(). - Services/FirebaseService.swift entero y sus 40 llamadas. Se borra en vez de dejarse como capa vacía: una clase llamada FirebaseService en una app que presume de no llevar Firebase es exactamente la clase de detalle que vuelve a colarse en una auditoría dentro de un año. - El banner y su safeAreaInset en ContentView (bannerInsetView): las cinco pestañas ya no reservan 50 pt al pie. - La entrada "Manage Ad Consent" de Ajustes y el @EnvironmentObject adMobService de SettingsView, ContentView y PortfolioJournalApp. - AppConstants: bloque AdMob, bannerAdHeight, adConsentObtained, Features.enableAnalytics. - SettingsViewModel.toggleAnalytics y analyticsEnabled — el flag no estaba conectado a nada ni tenía control en la interfaz. El atributo AppSettings.enableAnalytics se queda en CoreData con un comentario: no vale la pena una migración y un deploy de esquema CloudKit por borrarlo. - Premium ya no promete "sin anuncios": fuera PremiumFeature.noAds, la entrada de IAPService.premiumFeatures y paywallBenefits, y las claves feature_no_ads / paywall_benefit_noads de los 7 idiomas. No se toca ni el precio ni el producto. Info.plist: fuera NSUserTrackingUsageDescription, GADApplicationIdentifier, GADDelayAppMeasurementInit y los 65 SKAdNetworkItems. Borrados también GoogleService-Info.plist y los scripts Scripts/analyze_ga4.py y Scripts/analyze_crashlytics.py, que ya no tienen de dónde leer. Closes #50 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 */;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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: View>(_ 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyAregLaXQ-WqRQTltTRyjQx3-lfxLl4Cng</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>334225114072</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.alexandrevazquez.PortfolioJournal</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>portfoliojournal-ef2d7</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>portfoliojournal-ef2d7.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:334225114072:ios:a0a50b5835ac042e3d28ad</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -31,10 +31,6 @@
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-1549720748100858~9632507420</string>
|
||||
<key>GADDelayAppMeasurementInit</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
@@ -54,271 +50,6 @@
|
||||
<string>Use Face ID to unlock your portfolio data.</string>
|
||||
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
|
||||
<true/>
|
||||
<key>NSUserTrackingUsageDescription</key>
|
||||
<string>This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties.</string>
|
||||
<key>SKAdNetworkItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>cstr6suwn9.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>4fzdc2evr5.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>4pfyvq9l8r.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>2fnua5tdw4.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>ydx93a7ass.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>5a6flpkh64.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>p78axxw29g.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>v72qych5uu.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>ludvb6z3bs.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>cp8zw746q7.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>3sh42y64q3.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>c6k4g5qg8m.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>s39g8k73mm.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>3qy4746246.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>f38h382jlk.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>hs6bdukanm.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>v4nxqhlyqp.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>wzmmz9fp6w.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>yclnxrl5pm.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>t38b2kh725.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>7ug5zh24hu.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>gta9lk7p23.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>vutu7akeur.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>y5ghdn5j9k.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>n6fk4nfna4.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>v9wttpbfk9.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>n38lu8286q.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>47vhws6wlr.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>kbd757ber7.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>9t245vhmpl.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>eh6m2bh4zr.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>a2p9lx4jpn.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>22mmun2rn5.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>4468km3ulz.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>2u9pt9hc89.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>8s468mfl3y.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>klf5c3l5u5.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>ppxm28t8ap.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>ecpz2srf59.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>uw77j35x4d.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>pwa73g5rt2.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>mlmmfzh3r3.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>578prtvx9j.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>4dzt52r2t5.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>e5fvkxwrpn.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>8c4e2ghe7u.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>zq492l623r.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>3rd42ekr43.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>3qcr597p9d.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>su67r6k2v3.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>kbd757ywx3.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>44n7hlldy6.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>k674qkevps.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>2rq3zucswp.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>5tjdwbrq8w.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>97r2b46745.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>b9bk5wbcq9.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>44jx6755aq.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>kbmxgpxpgc.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>tl55sbb4fm.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>9g2aggbj52.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>g2y4y55b64.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>c3frkrj4fj.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>r26jy69rpl.skadnetwork</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>275upjj5gd.skadnetwork</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "通知";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Content: View>(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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? ""
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
]}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user