Improve subscription UI and fix trial date handling

Lock Screen:
- Add light/dark mode support with adaptive colors
- Make passcode text tappable to trigger authentication

Trial Date Fixes:
- Fix IAPManager to read firstLaunchDate directly from UserDefaults
- Add expiration date check (> Date()) before showing "expires in" text
- Show "Trial expired" when trial end date is in the past
- Disable subscription bypass in DEBUG mode for testing

Month/Year Subscribe Prompts:
- Redesign with gradient icons and compelling copy
- Add fade mask (100% at top to 0% at 50%) for content behind
- Position subscribe overlay on bottom half of screen
- Month: purple/pink theme with calendar icon
- Year: orange/pink theme with chart icon

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-24 10:54:01 -06:00
parent 745e226ecb
commit cc9f9f9427
8 changed files with 275 additions and 78 deletions

View File

@@ -33,8 +33,9 @@ class IAPManager: ObservableObject {
// MARK: - Debug Toggle
/// Set to `true` to bypass all subscription checks and grant full access (for development only)
/// Set to `false` to test trial/subscription behavior in DEBUG builds
#if DEBUG
static let bypassSubscription = true
static let bypassSubscription = false
#else
static let bypassSubscription = false
#endif
@@ -59,8 +60,16 @@ class IAPManager: ObservableObject {
// MARK: - Storage
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults)
private var firstLaunchDate = Date()
/// Reads firstLaunchDate directly from UserDefaults to ensure we always get the latest value
/// (Using @AppStorage in a class doesn't auto-sync when other components update the same key)
private var firstLaunchDate: Date {
get {
GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.firstLaunchDate.rawValue) as? Date ?? Date()
}
set {
GroupUserDefaults.groupDefaults.set(newValue, forKey: UserDefaultsStore.Keys.firstLaunchDate.rawValue)
}
}
// MARK: - Private

View File

@@ -21,15 +21,20 @@ struct IAPWarningView: View {
Image(systemName: "clock")
.foregroundColor(.orange)
Text(String(localized: "iap_warning_view_title"))
.font(.body)
.foregroundColor(textColor)
if let expirationDate = iapManager.trialExpirationDate, expirationDate > Date() {
Text(String(localized: "iap_warning_view_title"))
.font(.body)
.foregroundColor(textColor)
if let expirationDate = iapManager.trialExpirationDate {
Text(expirationDate, style: .relative)
.font(.body)
.bold()
.foregroundColor(.orange)
} else {
Text(String(localized: "purchase_view_trial_expired"))
.font(.body)
.bold()
.foregroundColor(.orange)
}
}

View File

@@ -23,6 +23,7 @@ struct MoodParticle: Identifiable {
// MARK: - Aurora Gradient Background
struct AuroraBackground: View {
@Environment(\.colorScheme) private var colorScheme
@State private var animateGradient = false
private let moodColors: [Color] = [
@@ -33,14 +34,20 @@ struct AuroraBackground: View {
Color(hex: "ff453a"), // horrible - red
]
private var isDark: Bool { colorScheme == .dark }
var body: some View {
ZStack {
// Base dark gradient
// Base gradient - adapts to color scheme
LinearGradient(
colors: [
colors: isDark ? [
Color(hex: "0a0a0f"),
Color(hex: "12121a"),
Color(hex: "0d0d14")
] : [
Color(hex: "f8f9fa"),
Color(hex: "e9ecef"),
Color(hex: "f1f3f5")
],
startPoint: .topLeading,
endPoint: .bottomTrailing
@@ -49,8 +56,8 @@ struct AuroraBackground: View {
// Aurora layer 1 - green/blue
EllipticalGradient(
colors: [
moodColors[0].opacity(0.3),
moodColors[2].opacity(0.15),
moodColors[0].opacity(isDark ? 0.3 : 0.2),
moodColors[2].opacity(isDark ? 0.15 : 0.1),
.clear
],
center: .topLeading,
@@ -63,8 +70,8 @@ struct AuroraBackground: View {
// Aurora layer 2 - yellow/orange
EllipticalGradient(
colors: [
moodColors[1].opacity(0.2),
moodColors[3].opacity(0.1),
moodColors[1].opacity(isDark ? 0.2 : 0.15),
moodColors[3].opacity(isDark ? 0.1 : 0.08),
.clear
],
center: .bottomTrailing,
@@ -77,7 +84,7 @@ struct AuroraBackground: View {
// Aurora layer 3 - subtle red accent
RadialGradient(
colors: [
moodColors[4].opacity(0.15),
moodColors[4].opacity(isDark ? 0.15 : 0.1),
.clear
],
center: UnitPoint(x: 0.8, y: 0.3),
@@ -89,7 +96,7 @@ struct AuroraBackground: View {
// Noise texture overlay
Rectangle()
.fill(.white.opacity(0.015))
.fill((isDark ? Color.white : Color.black).opacity(0.015))
.background(
Canvas { context, size in
for _ in 0..<1000 {
@@ -98,7 +105,7 @@ struct AuroraBackground: View {
let opacity = Double.random(in: 0.01...0.04)
context.fill(
Path(ellipseIn: CGRect(x: x, y: y, width: 1, height: 1)),
with: .color(.white.opacity(opacity))
with: .color((isDark ? Color.white : Color.black).opacity(opacity))
)
}
}
@@ -190,6 +197,7 @@ struct FloatingAnimation: ViewModifier {
// MARK: - Central Breathing Orb
struct BreathingOrb: View {
@Environment(\.colorScheme) private var colorScheme
@State private var breathe = false
@State private var rotate = false
@@ -201,6 +209,8 @@ struct BreathingOrb: View {
Color(hex: "ff453a"),
]
private var isDark: Bool { colorScheme == .dark }
var body: some View {
ZStack {
// Outer glow
@@ -215,7 +225,7 @@ struct BreathingOrb: View {
)
.frame(width: 180, height: 180)
.blur(radius: 40)
.opacity(0.6)
.opacity(isDark ? 0.6 : 0.5)
.scaleEffect(breathe ? 1.2 : 0.9)
.rotationEffect(.degrees(rotate ? 360 : 0))
@@ -229,7 +239,7 @@ struct BreathingOrb: View {
)
.frame(width: 120, height: 120)
.blur(radius: 20)
.opacity(0.8)
.opacity(isDark ? 0.8 : 0.6)
.scaleEffect(breathe ? 1.1 : 0.95)
.rotationEffect(.degrees(rotate ? -360 : 0))
@@ -237,10 +247,14 @@ struct BreathingOrb: View {
Circle()
.fill(
RadialGradient(
colors: [
colors: isDark ? [
.white.opacity(0.9),
.white.opacity(0.3),
.clear
] : [
.white,
.white.opacity(0.6),
.clear
],
center: .center,
startRadius: 0,
@@ -249,12 +263,13 @@ struct BreathingOrb: View {
)
.frame(width: 80, height: 80)
.scaleEffect(breathe ? 1.05 : 0.98)
.shadow(color: .black.opacity(isDark ? 0 : 0.1), radius: 10)
// Glossy highlight
Ellipse()
.fill(
LinearGradient(
colors: [.white.opacity(0.4), .clear],
colors: [.white.opacity(isDark ? 0.4 : 0.8), .clear],
startPoint: .top,
endPoint: .center
)
@@ -283,6 +298,7 @@ struct BreathingOrb: View {
// MARK: - Glassmorphic Button
struct GlassButton: View {
@Environment(\.colorScheme) private var colorScheme
let icon: String
let title: String
let action: () -> Void
@@ -290,6 +306,10 @@ struct GlassButton: View {
@State private var isPressed = false
@State private var pulse = false
private var isDark: Bool { colorScheme == .dark }
private var foregroundColor: Color { isDark ? .white : .primary }
private var accentOpacity: Double { isDark ? 0.15 : 0.1 }
var body: some View {
Button(action: action) {
HStack(spacing: 14) {
@@ -297,24 +317,24 @@ struct GlassButton: View {
// Pulse ring
Circle()
.stroke(lineWidth: 2)
.foregroundColor(.white.opacity(0.3))
.foregroundColor(foregroundColor.opacity(0.3))
.frame(width: 44, height: 44)
.scaleEffect(pulse ? 1.3 : 1)
.opacity(pulse ? 0 : 0.6)
// Icon background
Circle()
.fill(.white.opacity(0.15))
.fill(foregroundColor.opacity(accentOpacity))
.frame(width: 44, height: 44)
Image(systemName: icon)
.font(.system(size: 20, weight: .medium))
.foregroundColor(.white)
.foregroundColor(foregroundColor)
}
Text(title)
.font(.system(size: 17, weight: .semibold, design: .rounded))
.foregroundColor(.white)
.foregroundColor(foregroundColor)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
@@ -324,16 +344,15 @@ struct GlassButton: View {
// Glass effect
RoundedRectangle(cornerRadius: 20)
.fill(.ultraThinMaterial)
.opacity(0.8)
// Border gradient
RoundedRectangle(cornerRadius: 20)
.stroke(
LinearGradient(
colors: [
.white.opacity(0.4),
.white.opacity(0.1),
.white.opacity(0.2)
foregroundColor.opacity(0.3),
foregroundColor.opacity(0.1),
foregroundColor.opacity(0.15)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
@@ -341,11 +360,11 @@ struct GlassButton: View {
lineWidth: 1
)
// Inner shadow
// Inner highlight
RoundedRectangle(cornerRadius: 20)
.fill(
LinearGradient(
colors: [.white.opacity(0.1), .clear],
colors: [foregroundColor.opacity(0.08), .clear],
startPoint: .top,
endPoint: .bottom
)
@@ -382,11 +401,16 @@ struct GlassButton: View {
// MARK: - Main Lock Screen View
struct LockScreenView: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var authManager: BiometricAuthManager
@State private var showError = false
@State private var showContent = false
private var isDark: Bool { colorScheme == .dark }
private var primaryText: Color { isDark ? .white : .primary }
private var secondaryText: Color { isDark ? .white.opacity(0.7) : .secondary }
private var tertiaryText: Color { isDark ? .white.opacity(0.5) : .secondary.opacity(0.8) }
var body: some View {
ZStack {
// Aurora background
@@ -411,11 +435,11 @@ struct LockScreenView: View {
VStack(spacing: 12) {
Text("Your Feelings")
.font(.system(size: 32, weight: .light, design: .serif))
.foregroundColor(.white)
.foregroundColor(primaryText)
Text("are safe here")
.font(.system(size: 32, weight: .ultraLight, design: .serif))
.foregroundColor(.white.opacity(0.7))
.foregroundColor(secondaryText)
}
.opacity(showContent ? 1 : 0)
.offset(y: showContent ? 0 : 20)
@@ -425,7 +449,7 @@ struct LockScreenView: View {
Text("Authenticate to continue your journey")
.font(.system(size: 14, weight: .regular, design: .rounded))
.foregroundColor(.white.opacity(0.5))
.foregroundColor(tertiaryText)
.opacity(showContent ? 1 : 0)
Spacer()
@@ -447,13 +471,23 @@ struct LockScreenView: View {
.offset(y: showContent ? 0 : 30)
.padding(.horizontal, 32)
// Passcode hint
// Passcode button - tappable to authenticate with passcode
if authManager.canUseDevicePasscode {
Text("Or use your device passcode")
.font(.system(size: 12, weight: .regular, design: .rounded))
.foregroundColor(.white.opacity(0.35))
.padding(.top, 16)
.opacity(showContent ? 1 : 0)
Button {
Task {
let success = await authManager.authenticate()
if !success {
showError = true
}
}
} label: {
Text("Or use your device passcode")
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundColor(isDark ? .white.opacity(0.5) : .accentColor)
}
.disabled(authManager.isAuthenticating)
.padding(.top, 16)
.opacity(showContent ? 1 : 0)
}
Spacer()
@@ -490,12 +524,12 @@ struct LockScreenView: View {
// MARK: - Preview
#Preview("Lock Screen - Face ID") {
#Preview("Lock Screen - Dark") {
LockScreenView(authManager: BiometricAuthManager())
.preferredColorScheme(.dark)
}
#Preview("Lock Screen - Touch ID") {
#Preview("Lock Screen - Light") {
LockScreenView(authManager: BiometricAuthManager())
.preferredColorScheme(.dark)
.preferredColorScheme(.light)
}

View File

@@ -109,6 +109,21 @@ struct MonthView: View {
)
}
.scrollDisabled(iapManager.shouldShowPaywall)
.mask(
// Fade effect when paywall should show: 100% at top, 0% halfway down
iapManager.shouldShowPaywall ?
AnyView(
LinearGradient(
gradient: Gradient(stops: [
.init(color: .black, location: 0),
.init(color: .black, location: 0.3),
.init(color: .clear, location: 0.5)
]),
startPoint: .top,
endPoint: .bottom
)
) : AnyView(Color.black)
)
}
// Hidden text to trigger updates when custom tint changes
@@ -116,27 +131,72 @@ struct MonthView: View {
.hidden()
if iapManager.shouldShowPaywall {
// Paywall overlay - tap to show subscription store
Color.black.opacity(0.3)
.ignoresSafeArea()
.onTapGesture {
showSubscriptionStore = true
// Premium month history prompt - bottom half
VStack(spacing: 20) {
// Icon
ZStack {
Circle()
.fill(
LinearGradient(
colors: [.purple.opacity(0.2), .pink.opacity(0.2)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
Image(systemName: "calendar.badge.clock")
.font(.title)
.foregroundStyle(
LinearGradient(
colors: [.purple, .pink],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
VStack {
Spacer()
// Text
VStack(spacing: 10) {
Text("Explore Your Mood History")
.font(.title3.weight(.bold))
.foregroundColor(textColor)
.multilineTextAlignment(.center)
Text("See your complete monthly journey. Track patterns and understand what shapes your days.")
.font(.subheadline)
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
// Subscribe button
Button {
showSubscriptionStore = true
} label: {
Text(String(localized: "subscription_required_button"))
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(RoundedRectangle(cornerRadius: 10).fill(Color.pink))
HStack {
Image(systemName: "calendar")
Text("Unlock Full History")
}
.font(.headline.weight(.bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(
LinearGradient(
colors: [.purple, .pink],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding()
.padding(.horizontal, 24)
}
.padding(.vertical, 24)
.frame(maxWidth: .infinity)
.background(theme.currentTheme.bg)
.frame(maxHeight: .infinity, alignment: .bottom)
} else if iapManager.shouldShowTrialWarning {
VStack {
Spacer()

View File

@@ -180,8 +180,12 @@ struct PurchaseButtonView: View {
Image(systemName: "clock")
.foregroundColor(.orange)
if let expirationDate = iapManager.trialExpirationDate {
if let expirationDate = iapManager.trialExpirationDate, expirationDate > Date() {
Text("\(Text(String(localized: "purchase_view_trial_expires_in")).foregroundColor(textColor)) \(Text(expirationDate, style: .relative).foregroundColor(.orange).bold())")
} else {
Text(String(localized: "purchase_view_trial_expired"))
.foregroundColor(.orange)
.bold()
}
}
.font(.body)

View File

@@ -95,7 +95,7 @@ struct UpgradeBannerView: View {
.font(.subheadline.weight(.medium))
.foregroundColor(.orange)
if let expirationDate = trialExpirationDate {
if let expirationDate = trialExpirationDate, expirationDate > Date() {
Text("\(Text("Trial expires in ").font(.subheadline.weight(.medium)).foregroundColor(textColor.opacity(0.8)))\(Text(expirationDate, style: .relative).font(.subheadline.weight(.bold)).foregroundColor(.orange))")
} else {
Text("Trial expired")

View File

@@ -66,14 +66,14 @@ struct YearView: View {
}
.scrollDisabled(iapManager.shouldShowPaywall)
.mask(
// Fade effect when paywall should show: 100% at top, 0% at bottom
// Fade effect when paywall should show: 100% at top, 0% halfway down
iapManager.shouldShowPaywall ?
AnyView(
LinearGradient(
gradient: Gradient(stops: [
.init(color: .black, location: 0),
.init(color: .black, location: 0.3),
.init(color: .clear, location: 1.0)
.init(color: .clear, location: 0.5)
]),
startPoint: .top,
endPoint: .bottom
@@ -83,26 +83,72 @@ struct YearView: View {
}
if iapManager.shouldShowPaywall {
VStack {
Spacer()
VStack(spacing: 16) {
Text("Subscribe to see your full year")
.font(.headline)
.foregroundColor(textColor)
// Premium year overview prompt - bottom half
VStack(spacing: 20) {
// Icon
ZStack {
Circle()
.fill(
LinearGradient(
colors: [.orange.opacity(0.2), .pink.opacity(0.2)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
Button {
showSubscriptionStore = true
} label: {
Text(String(localized: "subscription_required_button"))
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(RoundedRectangle(cornerRadius: 10).fill(Color.pink))
}
Image(systemName: "chart.bar.xaxis")
.font(.title)
.foregroundStyle(
LinearGradient(
colors: [.orange, .pink],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
.padding()
// Text
VStack(spacing: 10) {
Text("See Your Year at a Glance")
.font(.title3.weight(.bold))
.foregroundColor(textColor)
.multilineTextAlignment(.center)
Text("Discover your emotional rhythm across the year. Spot trends and celebrate how far you've come.")
.font(.subheadline)
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
// Subscribe button
Button {
showSubscriptionStore = true
} label: {
HStack {
Image(systemName: "chart.bar.fill")
Text("Unlock Year Overview")
}
.font(.headline.weight(.bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(
LinearGradient(
colors: [.orange, .pink],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal, 24)
}
.padding(.vertical, 24)
.frame(maxWidth: .infinity)
.background(theme.currentTheme.bg)
.frame(maxHeight: .infinity, alignment: .bottom)
} else if iapManager.shouldShowTrialWarning {
VStack {
Spacer()