diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index 5b06de1..83ff470 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -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 */; diff --git a/PortfolioJournal/App/AppDelegate.swift b/PortfolioJournal/App/AppDelegate.swift index 7427286..57cd2b3 100644 --- a/PortfolioJournal/App/AppDelegate.swift +++ b/PortfolioJournal/App/AppDelegate.swift @@ -2,6 +2,7 @@ import UIKit import UserNotifications import FirebaseCore import FirebaseAnalytics +import FirebaseCrashlytics import GoogleMobileAds class AppDelegate: NSObject, UIApplicationDelegate { diff --git a/PortfolioJournal/App/PortfolioJournalApp.swift b/PortfolioJournal/App/PortfolioJournalApp.swift index 13f6071..30f68a1 100644 --- a/PortfolioJournal/App/PortfolioJournalApp.swift +++ b/PortfolioJournal/App/PortfolioJournalApp.swift @@ -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 } diff --git a/PortfolioJournal/Resources/en.lproj/Localizable.strings b/PortfolioJournal/Resources/en.lproj/Localizable.strings index 35de7d4..7b3fb49 100644 --- a/PortfolioJournal/Resources/en.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/en.lproj/Localizable.strings @@ -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."; diff --git a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings index 845bfb8..d2ea4af 100644 --- a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings @@ -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."; diff --git a/PortfolioJournal/Services/NotificationService.swift b/PortfolioJournal/Services/NotificationService.swift index 582ce8b..9239c33 100644 --- a/PortfolioJournal/Services/NotificationService.swift +++ b/PortfolioJournal/Services/NotificationService.swift @@ -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 { diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index 33293e4..41df107 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -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 diff --git a/PortfolioJournal/Views/Onboarding/OnboardingView.swift b/PortfolioJournal/Views/Onboarding/OnboardingView.swift index b14d023..8e5259c 100644 --- a/PortfolioJournal/Views/Onboarding/OnboardingView.swift +++ b/PortfolioJournal/Views/Onboarding/OnboardingView.swift @@ -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 diff --git a/PortfolioJournal/Views/Premium/PaywallView.swift b/PortfolioJournal/Views/Premium/PaywallView.swift index 0a3bbbc..3444acb 100644 --- a/PortfolioJournal/Views/Premium/PaywallView.swift +++ b/PortfolioJournal/Views/Premium/PaywallView.swift @@ -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.. [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)) diff --git a/PortfolioJournal/Views/Sources/AddSourceView.swift b/PortfolioJournal/Views/Sources/AddSourceView.swift index ce3ea8f..592e515 100644 --- a/PortfolioJournal/Views/Sources/AddSourceView.swift +++ b/PortfolioJournal/Views/Sources/AddSourceView.swift @@ -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) } } diff --git a/Scripts/archive_and_upload_appstore.sh b/Scripts/archive_and_upload_appstore.sh index e0344a2..7a0bd12 100755 --- a/Scripts/archive_and_upload_appstore.sh +++ b/Scripts/archive_and_upload_appstore.sh @@ -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() {