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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user