Redesign onboarding and paywall for 1.2.0

- Onboarding: replace feature-focused slides with outcome-focused copy
  ("Know exactly where you stand", "5 minutes a month is enough", etc.)
- Paywall: non-scrollable layout, reduce from 8 to 4 key benefits,
  remove transactional language (Unlock/Upgrade/Premium),
  new CTA "Get Full Access", floating close button
- Add IAPService.paywallBenefits for the condensed 4-item paywall list
- Update CompactPaywallBanner and PremiumLockOverlay copy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-02-20 12:51:13 +01:00
parent ace58e5b0f
commit edb04efc86
3 changed files with 128 additions and 128 deletions
@@ -260,4 +260,12 @@ extension IAPService {
("xmark.circle", "No Ads", "Ad-free experience forever"), ("xmark.circle", "No Ads", "Ad-free experience forever"),
("person.2", "Family Sharing", "Share with up to 5 family members") ("person.2", "Family Sharing", "Share with up to 5 family members")
] ]
/// Condensed benefits shown on the paywall (4 max, outcome-focused)
static let paywallBenefits: [(icon: String, title: String, subtitle: String)] = [
("clock.arrow.circlepath", "Your complete history", "Every snapshot, contribution, and gain since day one"),
("chart.bar.xaxis", "Charts that reveal patterns", "Allocation, drawdown, performance — all in one place"),
("wand.and.stars", "12-month forecasts", "See where your portfolio is likely heading"),
("xmark.circle", "No ads, ever", "Clean, focused experience with no distractions")
]
} }
@@ -14,28 +14,28 @@ struct OnboardingView: View {
private let pages: [OnboardingPage] = [ private let pages: [OnboardingPage] = [
OnboardingPage( OnboardingPage(
icon: "chart.pie.fill", icon: "chart.line.uptrend.xyaxis",
title: "Long-Term Tracking", title: "Know exactly where you stand",
description: "A calm, offline-first portfolio tracker for investors who update monthly.", description: "See your total wealth, real returns, and allocation — always up to date.",
color: .appPrimary color: .appPrimary
), ),
OnboardingPage( OnboardingPage(
icon: "calendar.circle.fill", icon: "checkmark.circle.fill",
title: "Monthly Check-ins", title: "5 minutes a month is enough",
description: "Build a deliberate habit. Update sources, log contributions, and add a short note.", description: "Log your values once a month. Portfolio Journal handles the math and shows your progress.",
color: .positiveGreen color: .positiveGreen
), ),
OnboardingPage( OnboardingPage(
icon: "bell.badge.fill", icon: "leaf.fill",
title: "Gentle Reminders", title: "Ignore the noise. Track the trend.",
description: "Get a monthly nudge to review your portfolio without realtime noise.", description: "Daily swings don't tell the real story. Your growth over months and years does.",
color: .appWarning color: .appSecondary
), ),
OnboardingPage( OnboardingPage(
icon: "leaf.fill", icon: "flag.checkered",
title: "Calm Mode", title: "Reach your financial goals",
description: "Hide short-term swings and focus on contributions and long-term growth.", description: "Set targets, track milestones, and see exactly how far you've come.",
color: .appSecondary color: .appWarning
) )
] ]
+106 -114
View File
@@ -9,54 +9,57 @@ struct PaywallView: View {
@State private var showingError = false @State private var showingError = false
var body: some View { var body: some View {
NavigationStack { ZStack(alignment: .topTrailing) {
ZStack { AppBackground()
AppBackground()
ScrollView { VStack(spacing: 0) {
VStack(spacing: 24) { // Header
// Header headerSection
headerSection .padding(.top, 48)
.padding(.horizontal, 24)
// Features List Spacer()
featuresSection
// Price Card // Key benefits
priceCard benefitsSection
.padding(.horizontal, 24)
// Purchase Button Spacer()
purchaseButton
// Restore Button // Bottom actions
restoreButton VStack(spacing: 12) {
priceLabel
// Legal purchaseButton
legalSection restoreButton
} legalSection
.padding()
} }
.padding(.horizontal, 24)
.padding(.bottom, 32)
} }
.navigationTitle("Upgrade to Premium")
.navigationBarTitleDisplayMode(.inline) // Close button
.toolbar { Button {
ToolbarItem(placement: .navigationBarLeading) { dismiss()
Button { } label: {
dismiss() Image(systemName: "xmark")
} label: { .font(.system(size: 14, weight: .semibold))
Image(systemName: "xmark") .foregroundColor(.secondary)
.foregroundColor(.secondary) .padding(10)
} .background(Color(.secondarySystemBackground))
} .clipShape(Circle())
} }
.alert("Error", isPresented: $showingError) { .padding(.top, 16)
Button("OK", role: .cancel) {} .padding(.trailing, 16)
} message: { }
Text(errorMessage ?? "An error occurred") .ignoresSafeArea()
} .alert("Error", isPresented: $showingError) {
.onChange(of: iapService.isPremium) { _, isPremium in Button("OK", role: .cancel) {}
if isPremium { } message: {
dismiss() Text(errorMessage ?? "An error occurred")
} }
.onChange(of: iapService.isPremium) { _, isPremium in
if isPremium {
dismiss()
} }
} }
} }
@@ -64,21 +67,20 @@ struct PaywallView: View {
// MARK: - Header Section // MARK: - Header Section
private var headerSection: some View { private var headerSection: some View {
VStack(spacing: 16) { VStack(spacing: 12) {
// Crown icon
ZStack { ZStack {
Circle() Circle()
.fill( .fill(
LinearGradient( LinearGradient(
colors: [Color.yellow.opacity(0.3), Color.orange.opacity(0.3)], colors: [Color.yellow.opacity(0.25), Color.orange.opacity(0.25)],
startPoint: .topLeading, startPoint: .topLeading,
endPoint: .bottomTrailing endPoint: .bottomTrailing
) )
) )
.frame(width: 80, height: 80) .frame(width: 72, height: 72)
Image(systemName: "crown.fill") Image(systemName: "crown.fill")
.font(.system(size: 36)) .font(.system(size: 30))
.foregroundStyle( .foregroundStyle(
LinearGradient( LinearGradient(
colors: [.yellow, .orange], colors: [.yellow, .orange],
@@ -88,65 +90,43 @@ struct PaywallView: View {
) )
} }
Text("Unlock Full Potential") Text("Your full portfolio,\nfully clear")
.font(.title.weight(.bold)) .font(.title.weight(.bold))
.multilineTextAlignment(.center)
Text("Get unlimited access to all features with a one-time purchase") Text("One payment. Every feature. Forever.")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
} }
// MARK: - Features Section // MARK: - Benefits Section
private var featuresSection: some View { private var benefitsSection: some View {
VStack(spacing: 12) { VStack(spacing: 10) {
ForEach(IAPService.premiumFeatures, id: \.title) { feature in ForEach(IAPService.paywallBenefits, id: \.title) { benefit in
FeatureRow( BenefitRow(icon: benefit.icon, title: benefit.title, subtitle: benefit.subtitle)
icon: feature.icon,
title: feature.title,
description: feature.description
)
} }
} }
.padding() .padding(20)
.background(Color(.systemBackground)) .background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius) .cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2) .shadow(color: .black.opacity(0.05), radius: 8, y: 2)
} }
// MARK: - Price Card // MARK: - Price Label
private var priceCard: some View { private var priceLabel: some View {
VStack(spacing: 8) { HStack(spacing: 4) {
Text(iapService.formattedPrice) Text(iapService.formattedPrice)
.font(.system(size: 48, weight: .bold, design: .rounded)) .font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundColor(.appPrimary) .foregroundColor(.appPrimary)
Text("One-time purchase") Text("· one-time · Family Sharing")
.font(.subheadline) .font(.footnote)
.foregroundColor(.secondary) .foregroundColor(.secondary)
HStack(spacing: 4) {
Image(systemName: "person.2.fill")
.font(.caption)
Text("Includes Family Sharing")
.font(.caption)
}
.foregroundColor(.appSecondary)
.padding(.top, 4)
} }
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.background(
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
.fill(Color.appPrimary.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
.stroke(Color.appPrimary, lineWidth: 2)
)
)
} }
// MARK: - Purchase Button // MARK: - Purchase Button
@@ -160,7 +140,7 @@ struct PaywallView: View {
ProgressView() ProgressView()
.tint(.white) .tint(.white)
} else { } else {
Text("Upgrade Now") Text("Get Full Access")
.font(.headline) .font(.headline)
} }
} }
@@ -189,21 +169,22 @@ struct PaywallView: View {
// MARK: - Legal Section // MARK: - Legal Section
private var legalSection: some View { private var legalSection: some View {
VStack(spacing: 8) { VStack(spacing: 4) {
Text("Payment will be charged to your Apple ID account. By purchasing, you agree to our Terms of Service and Privacy Policy.") Text("Payment charged to your Apple ID account.")
.font(.caption2) .font(.caption2)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
HStack(spacing: 16) { HStack(spacing: 12) {
Link("Terms", destination: URL(string: AppConstants.URLs.termsOfService)!) Link("Terms", destination: URL(string: AppConstants.URLs.termsOfService)!)
.font(.caption) .font(.caption2)
.foregroundColor(.secondary)
Link("Privacy", destination: URL(string: AppConstants.URLs.privacyPolicy)!) Link("Privacy", destination: URL(string: AppConstants.URLs.privacyPolicy)!)
.font(.caption) .font(.caption2)
.foregroundColor(.secondary)
} }
} }
.padding(.top, 8)
} }
// MARK: - Actions // MARK: - Actions
@@ -238,30 +219,25 @@ struct PaywallView: View {
} }
} }
// MARK: - Feature Row // MARK: - Benefit Row
struct FeatureRow: View { struct BenefitRow: View {
let icon: String let icon: String
let title: String let title: String
let description: String let subtitle: String
var body: some View { var body: some View {
HStack(spacing: 16) { HStack(spacing: 14) {
ZStack { Image(systemName: icon)
Circle() .font(.system(size: 20))
.fill(Color.appPrimary.opacity(0.1)) .foregroundColor(.appPrimary)
.frame(width: 44, height: 44) .frame(width: 28)
Image(systemName: icon) VStack(alignment: .leading, spacing: 1) {
.font(.system(size: 18))
.foregroundColor(.appPrimary)
}
VStack(alignment: .leading, spacing: 2) {
Text(title) Text(title)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
Text(description) Text(subtitle)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@@ -270,7 +246,21 @@ struct FeatureRow: View {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.positiveGreen) .foregroundColor(.positiveGreen)
.font(.system(size: 18))
} }
.padding(.vertical, 2)
}
}
// MARK: - Feature Row (kept for backward compatibility)
struct FeatureRow: View {
let icon: String
let title: String
let description: String
var body: some View {
BenefitRow(icon: icon, title: title, subtitle: description)
} }
} }
@@ -293,10 +283,10 @@ struct CompactPaywallBanner: View {
) )
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Unlock Premium") Text("Full access, one payment")
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
Text("Get unlimited access to all features") Text("Unlimited sources, advanced charts & more")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@@ -330,22 +320,24 @@ struct PremiumLockOverlay: View {
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemName: "lock.fill") Image(systemName: "crown.fill")
.font(.system(size: 32)) .font(.system(size: 32))
.foregroundColor(.appWarning) .foregroundStyle(
LinearGradient(
Text("Premium Feature") colors: [.yellow, .orange],
.font(.headline) startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Text(feature) Text(feature)
.font(.subheadline) .font(.headline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Button { Button {
showingPaywall = true showingPaywall = true
} label: { } label: {
Label("Unlock", systemImage: "crown.fill") Text("See full access")
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 10) .padding(.vertical, 10)