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

@@ -1183,6 +1183,10 @@
"comment" : "An accessibility label for the section of the settings view that indicates that Apple Health is not available on the user's device.",
"isCommentAutoGenerated" : true
},
"are safe here" : {
"comment" : "A description of the safety of the app's \"Aurora background\".",
"isCommentAutoGenerated" : true
},
"Are you sure you want to delete this mood entry? This cannot be undone." : {
"comment" : "An alert message displayed when the user attempts to delete a mood entry.",
"isCommentAutoGenerated" : true,
@@ -1269,6 +1273,7 @@
},
"Authenticate to access your mood data" : {
"comment" : "A description of the purpose of the authentication process in the lock screen.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"de" : {
@@ -1309,6 +1314,10 @@
}
}
},
"Authenticate to continue your journey" : {
"comment" : "A description of the action required to unlock the app.",
"isCommentAutoGenerated" : true
},
"Authentication Failed" : {
"comment" : "An alert title when authentication fails.",
"isCommentAutoGenerated" : true,
@@ -4310,6 +4319,10 @@
}
}
},
"Discover your emotional rhythm across the year. Spot trends and celebrate how far you've come." : {
"comment" : "A description of what the \"See Your Year at a Glance\" feature does.",
"isCommentAutoGenerated" : true
},
"Don't break your streak!" : {
"comment" : "A description of the current streak or a motivational message.",
"isCommentAutoGenerated" : true,
@@ -4692,6 +4705,10 @@
}
}
},
"Explore Your Mood History" : {
"comment" : "A title for a feature that allows users to explore their mood history.",
"isCommentAutoGenerated" : true
},
"Export" : {
"comment" : "A button label that triggers data export.",
"isCommentAutoGenerated" : true,
@@ -5076,6 +5093,7 @@
},
"Feels is Locked" : {
"comment" : "The title of the lock screen.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"de" : {
@@ -10781,6 +10799,13 @@
}
}
},
"See your complete monthly journey. Track patterns and understand what shapes your days." : {
},
"See Your Year at a Glance" : {
"comment" : "A title for a feature that lets users see their year's emotional trends.",
"isCommentAutoGenerated" : true
},
"Select this mood" : {
"comment" : "A hint that appears when a user taps on a mood button.",
"isCommentAutoGenerated" : true
@@ -11653,6 +11678,7 @@
},
"Subscribe to see your full year" : {
"comment" : "A button label that appears when the user is subscribed to Feels.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"de" : {
@@ -13088,6 +13114,10 @@
}
}
},
"Unlock Full History" : {
"comment" : "A button label that appears when a user is on a free trial and wants to unlock all of their mood history.",
"isCommentAutoGenerated" : true
},
"Unlock Premium" : {
"comment" : "A button label that says \"Unlock Premium\".",
"isCommentAutoGenerated" : true,
@@ -13174,6 +13204,7 @@
},
"Unlock with %@" : {
"comment" : "A button label that instructs the user to unlock their device using their biometric authentication method. The text inside the parentheses is replaced with the name of the biometric authentication method available on the user's device (e.g",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"de" : {
@@ -13214,6 +13245,10 @@
}
}
},
"Unlock Year Overview" : {
"comment" : "A button label that appears when the user is not a premium subscriber, encouraging them to subscribe to unlock more features.",
"isCommentAutoGenerated" : true
},
"Use Siri to Log Moods" : {
"localizations" : {
"de" : {
@@ -14186,6 +14221,10 @@
}
}
}
},
"Your Feelings" : {
"comment" : "The title of the main screen in the lock screen.",
"isCommentAutoGenerated" : true
}
},
"version" : "1.1"

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()