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:
alexandrev-tibco
2026-05-01 09:26:37 +02:00
parent 10f6d0ca20
commit 94ed4d17eb
11 changed files with 340 additions and 131 deletions
+1
View File
@@ -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)
}
}