Release 1.3.0: onboarding UX, charts paywall banner, re-engagement notifications, Crashlytics
- Crashlytics: add FirebaseCrashlytics framework + dSYM upload build phase - Onboarding: useSampleData off by default, Add First Investment as primary CTA - AddSourceView: contextual placeholder and footer explaining what a source is - Charts: CompactPaywallBanner visible to free users on every visit - Notifications: re-engagement (7d) and monthly check-in local notifications - Paywall: decorative chart preview replacing static crown icon - Localization: all new strings in en + es-ES
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
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 */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -128,6 +129,7 @@
|
||||
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */,
|
||||
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */,
|
||||
0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */,
|
||||
0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -202,6 +204,7 @@
|
||||
0E241E362F0DA93A00283E2F /* Frameworks */,
|
||||
0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */,
|
||||
0E8318932F0DB2FB0030C2F9 /* Resources */,
|
||||
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -216,6 +219,7 @@
|
||||
0E53752A2F0FD08100F31390 /* FirebaseCore */,
|
||||
0E53752C2F0FD08600F31390 /* FirebaseAnalytics */,
|
||||
0E5375302F0FD12E00F31390 /* GoogleMobileAds */,
|
||||
0E5375342F0FD14000F31390 /* FirebaseCrashlytics */,
|
||||
);
|
||||
productName = PortfolioJournal;
|
||||
productReference = 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */;
|
||||
@@ -405,6 +409,29 @@
|
||||
};
|
||||
/* 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}",
|
||||
"$(SRCROOT)/$(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 */ = {
|
||||
isa = PBXTargetDependency;
|
||||
@@ -431,7 +458,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -449,7 +476,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -469,7 +496,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -487,7 +514,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -626,7 +653,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -639,7 +666,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -659,7 +686,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -672,7 +699,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -690,7 +717,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -699,7 +726,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -714,7 +741,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -723,7 +750,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -737,7 +764,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -746,7 +773,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -760,7 +787,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -769,7 +796,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
@@ -864,6 +891,11 @@
|
||||
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 */;
|
||||
|
||||
@@ -2,6 +2,7 @@ import UIKit
|
||||
import UserNotifications
|
||||
import FirebaseCore
|
||||
import FirebaseAnalytics
|
||||
import FirebaseCrashlytics
|
||||
import GoogleMobileAds
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
@@ -38,6 +38,8 @@ struct PortfolioJournalApp: App {
|
||||
// iCloud changes made on other devices while this device was inactive
|
||||
// are reflected immediately without waiting for a remote-change notification.
|
||||
coreDataStack.refreshFromCloudKit()
|
||||
NotificationService.shared.scheduleReEngagementNotification()
|
||||
NotificationService.shared.scheduleMonthlyCheckIn()
|
||||
} else if newPhase == .background {
|
||||
guard iapService.isPremium else { return }
|
||||
guard UserDefaults.standard.bool(forKey: "backupsEnabled") else { return }
|
||||
|
||||
@@ -280,3 +280,19 @@
|
||||
// MARK: - Monthly Check-in Card
|
||||
"checkin_update_month" = "Update %@";
|
||||
"checkin_start_new" = "Start";
|
||||
|
||||
// MARK: - Re-engagement & Monthly Notifications (1.2.1)
|
||||
"reengagement_title" = "Your portfolio is waiting";
|
||||
"reengagement_body" = "A few minutes is all it takes to stay on top of your investments.";
|
||||
"monthly_checkin_notification_title" = "Time for your monthly update";
|
||||
"monthly_checkin_notification_body" = "Log this month's values and track your portfolio growth.";
|
||||
|
||||
// MARK: - Onboarding QuickStart (1.3.0)
|
||||
"onboarding_quickstart_title" = "Almost there";
|
||||
"onboarding_quickstart_subtitle" = "Add your first investment source to start tracking your portfolio.";
|
||||
"onboarding_add_first_source" = "Add My First Investment";
|
||||
"onboarding_import_data" = "Import Existing Data";
|
||||
|
||||
// MARK: - AddSourceView (1.3.0)
|
||||
"add_source_name_placeholder" = "e.g. MSCI World ETF, ING Savings, Apartment...";
|
||||
"add_source_name_footer" = "A source is any investment you want to track: stocks, ETFs, savings accounts, real estate, crypto, and more.";
|
||||
|
||||
@@ -202,3 +202,26 @@
|
||||
"Best Contributor" = "Mayor aportador";
|
||||
"Update Check-in" = "Actualizar chequeo";
|
||||
"Completed %@" = "Completado %@";
|
||||
|
||||
// MARK: - Paywall literals (1.2.1)
|
||||
"Your full portfolio,\nfully clear" = "Tu portfolio completo,\nen todo su potencial";
|
||||
"One payment. Every feature. Forever." = "Un solo pago. Todas las funciones. Para siempre.";
|
||||
"Get Full Access" = "Obtener acceso completo";
|
||||
"Restore Purchases" = "Restaurar compras";
|
||||
"Payment charged to your Apple ID account." = "El cobro se realizará a tu cuenta de Apple ID.";
|
||||
|
||||
// MARK: - Re-engagement & Monthly Notifications (1.2.1)
|
||||
"reengagement_title" = "Tu portfolio te espera";
|
||||
"reengagement_body" = "Unos minutos son suficientes para mantener el control de tus inversiones.";
|
||||
"monthly_checkin_notification_title" = "Ya puedes actualizar tu portfolio";
|
||||
"monthly_checkin_notification_body" = "Registra los valores de este mes y sigue la evolución de tu cartera.";
|
||||
|
||||
// MARK: - Onboarding QuickStart (1.3.0)
|
||||
"onboarding_quickstart_title" = "Ya casi está";
|
||||
"onboarding_quickstart_subtitle" = "Añade tu primera fuente de inversión para empezar a seguir tu portfolio.";
|
||||
"onboarding_add_first_source" = "Añadir mi primera inversión";
|
||||
"onboarding_import_data" = "Importar datos existentes";
|
||||
|
||||
// MARK: - AddSourceView (1.3.0)
|
||||
"add_source_name_placeholder" = "p.ej. ETF MSCI World, Cuenta ING, Piso...";
|
||||
"add_source_name_footer" = "Una fuente es cualquier inversión que quieras seguir: acciones, ETFs, cuentas de ahorro, inmuebles, cripto y más.";
|
||||
|
||||
@@ -176,6 +176,62 @@ extension Notification.Name {
|
||||
static let didResetData = Notification.Name("didResetData")
|
||||
}
|
||||
|
||||
// MARK: - Re-engagement Notifications
|
||||
|
||||
extension NotificationService {
|
||||
/// Schedules a re-engagement notification 7 days from now.
|
||||
/// Call this every time the app becomes active to reset the timer.
|
||||
func scheduleReEngagementNotification() {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
center.removePendingNotificationRequests(withIdentifiers: ["re_engagement"])
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "reengagement_title")
|
||||
content.body = String(localized: "reengagement_body")
|
||||
content.sound = .default
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 7 * 24 * 3600, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: "re_engagement", content: content, trigger: trigger)
|
||||
|
||||
center.add(request) { error in
|
||||
if let error = error {
|
||||
print("Re-engagement notification error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules a monthly check-in notification on the 1st of each month at 9am.
|
||||
/// Only schedules if not already pending.
|
||||
func scheduleMonthlyCheckIn() {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
let identifier = "monthly_checkin"
|
||||
center.getPendingNotificationRequests { [weak self] requests in
|
||||
guard let self, !requests.contains(where: { $0.identifier == identifier }) else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "monthly_checkin_notification_title")
|
||||
content.body = String(localized: "monthly_checkin_notification_body")
|
||||
content.sound = .default
|
||||
|
||||
var components = DateComponents()
|
||||
components.day = 1
|
||||
components.hour = 9
|
||||
components.minute = 0
|
||||
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
|
||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
||||
|
||||
self.center.add(request) { error in
|
||||
if let error = error {
|
||||
print("Monthly check-in notification error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Refresh
|
||||
|
||||
extension NotificationService {
|
||||
|
||||
@@ -22,6 +22,14 @@ struct ChartsContainerView: View {
|
||||
// Chart Type Selector
|
||||
chartTypeSelector
|
||||
|
||||
// Paywall nudge for free users
|
||||
if !viewModel.isPremium {
|
||||
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
||||
.onAppear {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
|
||||
}
|
||||
}
|
||||
|
||||
// Unified Filters
|
||||
filtersSection
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ struct OnboardingView: View {
|
||||
|
||||
@State private var currentPage = 0
|
||||
@State private var selectedCurrency = Locale.current.currency?.identifier ?? "EUR"
|
||||
@State private var useSampleData = true
|
||||
@State private var useSampleData = false
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||
@State private var showingImportSheet = false
|
||||
@@ -221,109 +221,82 @@ struct OnboardingQuickStartView: View {
|
||||
let onAddSource: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
ScrollView {
|
||||
VStack(spacing: 28) {
|
||||
Spacer().frame(height: 8)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 72, height: 72)
|
||||
// Header
|
||||
VStack(spacing: 10) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
|
||||
Text("Quick Start")
|
||||
.font(.title.weight(.bold))
|
||||
Text(String(localized: "onboarding_quickstart_title"))
|
||||
.font(.title.weight(.bold))
|
||||
|
||||
Text("Pick your currency and start with sample data or import your own.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 30)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("Currency")
|
||||
Spacer()
|
||||
Picker("Currency", selection: $selectedCurrency) {
|
||||
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Load sample portfolio", isOn: $useSampleData)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Enable Calm Mode (recommended)", isOn: $calmModeEnabled)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
if cloudSyncEnabled {
|
||||
Text("iCloud sync starts after you restart the app.")
|
||||
.font(.caption)
|
||||
Text(String(localized: "onboarding_quickstart_subtitle"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button {
|
||||
openAppSettings()
|
||||
} label: {
|
||||
Label("Open iCloud Settings", systemImage: "gear")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 30)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
onImport()
|
||||
} label: {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
// Primary CTA — Add first source
|
||||
VStack(spacing: 10) {
|
||||
Button(action: onAddSource) {
|
||||
Label(String(localized: "onboarding_add_first_source"), systemImage: "plus.circle.fill")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
|
||||
Button(action: onImport) {
|
||||
Label(String(localized: "onboarding_import_data"), systemImage: "square.and.arrow.down")
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.foregroundColor(.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary options
|
||||
VStack(spacing: 10) {
|
||||
HStack {
|
||||
Text("Currency")
|
||||
Spacer()
|
||||
Picker("Currency", selection: $selectedCurrency) {
|
||||
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Button {
|
||||
onAddSource()
|
||||
} label: {
|
||||
Label("Add Source", systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appSecondary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
Toggle("Enable Calm Mode (recommended)", isOn: $calmModeEnabled)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 8)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func openAppSettings() {
|
||||
guard let url = Self.appSettingsURL() else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
static func appSettingsURL() -> URL? {
|
||||
URL(string: UIApplication.openSettingsURLString)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - First Launch Welcome
|
||||
|
||||
@@ -1,5 +1,93 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Chart Preview (decorative, no real data)
|
||||
|
||||
private struct PremiumChartPreview: View {
|
||||
// Normalized growth data representing a healthy upward portfolio trend
|
||||
private let points: [Double] = [0.52, 0.58, 0.55, 0.65, 0.70, 0.66, 0.78, 0.85, 0.82, 0.91, 0.88, 1.0]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.appPrimary.opacity(0.08))
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("€24,750")
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Text("+34.2%")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.positiveGreen)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.positiveGreen.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 6)
|
||||
|
||||
GeometryReader { geo in
|
||||
chartPaths(in: geo.size)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.frame(height: 96)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func chartPaths(in size: CGSize) -> some View {
|
||||
let pts = chartPoints(in: size)
|
||||
|
||||
// Gradient fill
|
||||
Path { path in
|
||||
guard !pts.isEmpty else { return }
|
||||
path.move(to: CGPoint(x: pts[0].x, y: size.height))
|
||||
path.addLine(to: pts[0])
|
||||
for i in 1..<pts.count {
|
||||
let ctrl = CGPoint(x: (pts[i-1].x + pts[i].x) / 2, y: (pts[i-1].y + pts[i].y) / 2)
|
||||
path.addQuadCurve(to: pts[i], control: ctrl)
|
||||
}
|
||||
path.addLine(to: CGPoint(x: pts.last!.x, y: size.height))
|
||||
path.closeSubpath()
|
||||
}
|
||||
.fill(LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.25), Color.appPrimary.opacity(0.0)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
))
|
||||
|
||||
// Line
|
||||
Path { path in
|
||||
guard !pts.isEmpty else { return }
|
||||
path.move(to: pts[0])
|
||||
for i in 1..<pts.count {
|
||||
let ctrl = CGPoint(x: (pts[i-1].x + pts[i].x) / 2, y: (pts[i-1].y + pts[i].y) / 2)
|
||||
path.addQuadCurve(to: pts[i], control: ctrl)
|
||||
}
|
||||
}
|
||||
.stroke(Color.appPrimary, lineWidth: 2)
|
||||
|
||||
// Last point dot
|
||||
if let last = pts.last {
|
||||
Circle()
|
||||
.fill(Color.appPrimary)
|
||||
.frame(width: 7, height: 7)
|
||||
.position(last)
|
||||
}
|
||||
}
|
||||
|
||||
private func chartPoints(in size: CGSize) -> [CGPoint] {
|
||||
guard points.count > 1 else { return [] }
|
||||
let stepX = size.width / CGFloat(points.count - 1)
|
||||
return points.enumerated().map { i, val in
|
||||
CGPoint(x: CGFloat(i) * stepX, y: size.height * (1.0 - val * 0.85))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PaywallView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
@@ -68,27 +156,7 @@ struct PaywallView: View {
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.yellow.opacity(0.25), Color.orange.opacity(0.25)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: 30))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.yellow, .orange],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
}
|
||||
PremiumChartPreview()
|
||||
|
||||
Text("Your full portfolio,\nfully clear")
|
||||
.font(.title.weight(.bold))
|
||||
|
||||
@@ -39,7 +39,7 @@ struct AddSourceView: View {
|
||||
|
||||
// Source Info
|
||||
Section {
|
||||
TextField("Source Name", text: $name)
|
||||
TextField(String(localized: "add_source_name_placeholder"), text: $name)
|
||||
.textContentType(.organizationName)
|
||||
.onChange(of: name) { _, newValue in
|
||||
validateSourceName(newValue)
|
||||
@@ -77,6 +77,9 @@ struct AddSourceView: View {
|
||||
if let error = duplicateError {
|
||||
Text(error)
|
||||
.foregroundColor(.negativeRed)
|
||||
} else {
|
||||
Text(String(localized: "add_source_name_footer"))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,21 @@ ARCHIVE_DIR="${ARCHIVE_DIR:-$ROOT_DIR/build/appstore}"
|
||||
ARCHIVE_PATH="${ARCHIVE_PATH:-$ARCHIVE_DIR/${SCHEME}.xcarchive}"
|
||||
EXPORT_PATH="${EXPORT_PATH:-$ARCHIVE_DIR/export}"
|
||||
EXPORT_OPTIONS_PLIST="${EXPORT_OPTIONS_PLIST:-$ARCHIVE_DIR/ExportOptions.plist}"
|
||||
KEY_ID="${APPSTORE_KEY_ID:-YJLSGZGP79}"
|
||||
ISSUER_ID="${APPSTORE_ISSUER_ID:-33f82bb1-0dbd-4b3f-a0ef-1a863dfd9453}"
|
||||
TEAM_ID="${DEVELOPMENT_TEAM_ID:-2825Q76T7H}"
|
||||
# Credenciales desde pass (GPG store). Se puede sobreescribir con variables de entorno.
|
||||
_pass_or_env() {
|
||||
local env_val="$1" pass_key="$2"
|
||||
if [[ -n "$env_val" ]]; then
|
||||
echo "$env_val"
|
||||
elif command -v pass >/dev/null 2>&1; then
|
||||
pass show "$pass_key" 2>/dev/null || fail "No se encontro la credencial en pass: $pass_key"
|
||||
else
|
||||
fail "pass no esta disponible y la variable de entorno no esta definida para: $pass_key"
|
||||
fi
|
||||
}
|
||||
|
||||
KEY_ID="$(_pass_or_env "${APPSTORE_KEY_ID:-}" appstore/api-key-id)"
|
||||
ISSUER_ID="$(_pass_or_env "${APPSTORE_ISSUER_ID:-}" appstore/issuer-id)"
|
||||
TEAM_ID="$(_pass_or_env "${DEVELOPMENT_TEAM_ID:-}" appstore/team-id)"
|
||||
P8_PATH="${APPSTORE_P8_PATH:-}"
|
||||
P8_DIR=""
|
||||
|
||||
@@ -22,18 +34,33 @@ fail() {
|
||||
}
|
||||
|
||||
find_p8_file() {
|
||||
# 1. Variable de entorno explícita
|
||||
if [[ -n "$P8_PATH" ]]; then
|
||||
[[ -f "$P8_PATH" ]] || fail "No existe el fichero APPSTORE_P8_PATH=$P8_PATH"
|
||||
echo "$P8_PATH"
|
||||
return
|
||||
fi
|
||||
|
||||
# 2. Extraer de pass y escribir en fichero temporal
|
||||
if command -v pass >/dev/null 2>&1 && pass show appstore/api-key-p8 &>/dev/null; then
|
||||
local tmp_p8
|
||||
tmp_p8="$(mktemp /tmp/AuthKey_XXXXXX.p8)"
|
||||
pass show appstore/api-key-p8 > "$tmp_p8"
|
||||
chmod 600 "$tmp_p8"
|
||||
# Registrar para limpieza al salir
|
||||
trap "rm -f '$tmp_p8'" EXIT
|
||||
echo "$tmp_p8"
|
||||
return
|
||||
fi
|
||||
|
||||
# 3. Fichero por defecto en HOME
|
||||
local default_path="$HOME/AuthKey_${KEY_ID}.p8"
|
||||
if [[ -f "$default_path" ]]; then
|
||||
echo "$default_path"
|
||||
return
|
||||
fi
|
||||
|
||||
# 4. Único .p8 en HOME
|
||||
local matches=("${HOME}"/*.p8(N))
|
||||
if (( ${#matches[@]} == 1 )); then
|
||||
echo "${matches[1]}"
|
||||
@@ -41,10 +68,10 @@ find_p8_file() {
|
||||
fi
|
||||
|
||||
if (( ${#matches[@]} > 1 )); then
|
||||
fail "Hay varios ficheros .p8 en $HOME. Define APPSTORE_P8_PATH con la ruta correcta."
|
||||
fail "Hay varios ficheros .p8 en $HOME. Define APPSTORE_P8_PATH o inserta la clave en pass: appstore/api-key-p8"
|
||||
fi
|
||||
|
||||
fail "No se encontro AuthKey_${KEY_ID}.p8 ni un unico fichero .p8 en $HOME."
|
||||
fail "No se encontro la clave .p8. Insértala con: cat AuthKey.p8 | pass insert -f -e appstore/api-key-p8"
|
||||
}
|
||||
|
||||
create_export_options() {
|
||||
|
||||
Reference in New Issue
Block a user