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>
536 lines
17 KiB
Swift
536 lines
17 KiB
Swift
//
|
|
// LockScreenView.swift
|
|
// Feels
|
|
//
|
|
// Lock screen shown when privacy lock is enabled and app needs authentication.
|
|
// Design: "Emotional Aurora" - A sanctuary for your feelings
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
// MARK: - Floating Mood Particle
|
|
|
|
struct MoodParticle: Identifiable {
|
|
let id = UUID()
|
|
var x: CGFloat
|
|
var y: CGFloat
|
|
let size: CGFloat
|
|
let color: Color
|
|
let duration: Double
|
|
let delay: Double
|
|
}
|
|
|
|
// MARK: - Aurora Gradient Background
|
|
|
|
struct AuroraBackground: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@State private var animateGradient = false
|
|
|
|
private let moodColors: [Color] = [
|
|
Color(hex: "31d158"), // great - green
|
|
Color(hex: "ffd709"), // good - yellow
|
|
Color(hex: "0b84ff"), // average - blue
|
|
Color(hex: "ff9e0b"), // bad - orange
|
|
Color(hex: "ff453a"), // horrible - red
|
|
]
|
|
|
|
private var isDark: Bool { colorScheme == .dark }
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Base gradient - adapts to color scheme
|
|
LinearGradient(
|
|
colors: isDark ? [
|
|
Color(hex: "0a0a0f"),
|
|
Color(hex: "12121a"),
|
|
Color(hex: "0d0d14")
|
|
] : [
|
|
Color(hex: "f8f9fa"),
|
|
Color(hex: "e9ecef"),
|
|
Color(hex: "f1f3f5")
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
|
|
// Aurora layer 1 - green/blue
|
|
EllipticalGradient(
|
|
colors: [
|
|
moodColors[0].opacity(isDark ? 0.3 : 0.2),
|
|
moodColors[2].opacity(isDark ? 0.15 : 0.1),
|
|
.clear
|
|
],
|
|
center: .topLeading,
|
|
startRadiusFraction: 0,
|
|
endRadiusFraction: 0.8
|
|
)
|
|
.blur(radius: 60)
|
|
.offset(x: animateGradient ? 20 : -20, y: animateGradient ? -30 : 30)
|
|
|
|
// Aurora layer 2 - yellow/orange
|
|
EllipticalGradient(
|
|
colors: [
|
|
moodColors[1].opacity(isDark ? 0.2 : 0.15),
|
|
moodColors[3].opacity(isDark ? 0.1 : 0.08),
|
|
.clear
|
|
],
|
|
center: .bottomTrailing,
|
|
startRadiusFraction: 0,
|
|
endRadiusFraction: 0.7
|
|
)
|
|
.blur(radius: 80)
|
|
.offset(x: animateGradient ? -30 : 30, y: animateGradient ? 20 : -20)
|
|
|
|
// Aurora layer 3 - subtle red accent
|
|
RadialGradient(
|
|
colors: [
|
|
moodColors[4].opacity(isDark ? 0.15 : 0.1),
|
|
.clear
|
|
],
|
|
center: UnitPoint(x: 0.8, y: 0.3),
|
|
startRadius: 0,
|
|
endRadius: 200
|
|
)
|
|
.blur(radius: 40)
|
|
.offset(y: animateGradient ? -10 : 10)
|
|
|
|
// Noise texture overlay
|
|
Rectangle()
|
|
.fill((isDark ? Color.white : Color.black).opacity(0.015))
|
|
.background(
|
|
Canvas { context, size in
|
|
for _ in 0..<1000 {
|
|
let x = CGFloat.random(in: 0...size.width)
|
|
let y = CGFloat.random(in: 0...size.height)
|
|
let opacity = Double.random(in: 0.01...0.04)
|
|
context.fill(
|
|
Path(ellipseIn: CGRect(x: x, y: y, width: 1, height: 1)),
|
|
with: .color((isDark ? Color.white : Color.black).opacity(opacity))
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
.ignoresSafeArea()
|
|
.onAppear {
|
|
withAnimation(
|
|
.easeInOut(duration: 8)
|
|
.repeatForever(autoreverses: true)
|
|
) {
|
|
animateGradient = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Floating Particles Layer
|
|
|
|
struct FloatingParticlesView: View {
|
|
@State private var particles: [MoodParticle] = []
|
|
|
|
private let moodColors: [Color] = [
|
|
Color(hex: "31d158"),
|
|
Color(hex: "ffd709"),
|
|
Color(hex: "0b84ff"),
|
|
Color(hex: "ff9e0b"),
|
|
Color(hex: "ff453a"),
|
|
]
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
ZStack {
|
|
ForEach(particles) { particle in
|
|
Circle()
|
|
.fill(particle.color)
|
|
.frame(width: particle.size, height: particle.size)
|
|
.blur(radius: particle.size * 0.3)
|
|
.position(x: particle.x, y: particle.y)
|
|
.modifier(FloatingAnimation(
|
|
startY: particle.y,
|
|
duration: particle.duration,
|
|
delay: particle.delay
|
|
))
|
|
}
|
|
}
|
|
.onAppear {
|
|
generateParticles(in: geo.size)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func generateParticles(in size: CGSize) {
|
|
particles = (0..<15).map { _ in
|
|
MoodParticle(
|
|
x: CGFloat.random(in: 0...size.width),
|
|
y: CGFloat.random(in: 0...size.height),
|
|
size: CGFloat.random(in: 4...12),
|
|
color: moodColors.randomElement()!.opacity(Double.random(in: 0.2...0.5)),
|
|
duration: Double.random(in: 6...12),
|
|
delay: Double.random(in: 0...3)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct FloatingAnimation: ViewModifier {
|
|
let startY: CGFloat
|
|
let duration: Double
|
|
let delay: Double
|
|
|
|
@State private var offset: CGFloat = 0
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.offset(y: offset)
|
|
.onAppear {
|
|
withAnimation(
|
|
.easeInOut(duration: duration)
|
|
.repeatForever(autoreverses: true)
|
|
.delay(delay)
|
|
) {
|
|
offset = CGFloat.random(in: -30...30)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Central Breathing Orb
|
|
|
|
struct BreathingOrb: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@State private var breathe = false
|
|
@State private var rotate = false
|
|
|
|
private let moodColors: [Color] = [
|
|
Color(hex: "31d158"),
|
|
Color(hex: "ffd709"),
|
|
Color(hex: "0b84ff"),
|
|
Color(hex: "ff9e0b"),
|
|
Color(hex: "ff453a"),
|
|
]
|
|
|
|
private var isDark: Bool { colorScheme == .dark }
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Outer glow
|
|
Circle()
|
|
.fill(
|
|
AngularGradient(
|
|
colors: moodColors + [moodColors[0]],
|
|
center: .center,
|
|
startAngle: .degrees(0),
|
|
endAngle: .degrees(360)
|
|
)
|
|
)
|
|
.frame(width: 180, height: 180)
|
|
.blur(radius: 40)
|
|
.opacity(isDark ? 0.6 : 0.5)
|
|
.scaleEffect(breathe ? 1.2 : 0.9)
|
|
.rotationEffect(.degrees(rotate ? 360 : 0))
|
|
|
|
// Middle ring
|
|
Circle()
|
|
.fill(
|
|
AngularGradient(
|
|
colors: moodColors.reversed() + [moodColors.last!],
|
|
center: .center
|
|
)
|
|
)
|
|
.frame(width: 120, height: 120)
|
|
.blur(radius: 20)
|
|
.opacity(isDark ? 0.8 : 0.6)
|
|
.scaleEffect(breathe ? 1.1 : 0.95)
|
|
.rotationEffect(.degrees(rotate ? -360 : 0))
|
|
|
|
// Inner core
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: isDark ? [
|
|
.white.opacity(0.9),
|
|
.white.opacity(0.3),
|
|
.clear
|
|
] : [
|
|
.white,
|
|
.white.opacity(0.6),
|
|
.clear
|
|
],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: 40
|
|
)
|
|
)
|
|
.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(isDark ? 0.4 : 0.8), .clear],
|
|
startPoint: .top,
|
|
endPoint: .center
|
|
)
|
|
)
|
|
.frame(width: 50, height: 30)
|
|
.offset(y: -15)
|
|
.scaleEffect(breathe ? 1.05 : 0.98)
|
|
}
|
|
.onAppear {
|
|
withAnimation(
|
|
.easeInOut(duration: 4)
|
|
.repeatForever(autoreverses: true)
|
|
) {
|
|
breathe = true
|
|
}
|
|
withAnimation(
|
|
.linear(duration: 20)
|
|
.repeatForever(autoreverses: false)
|
|
) {
|
|
rotate = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Glassmorphic Button
|
|
|
|
struct GlassButton: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
let icon: String
|
|
let title: String
|
|
let action: () -> Void
|
|
|
|
@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) {
|
|
ZStack {
|
|
// Pulse ring
|
|
Circle()
|
|
.stroke(lineWidth: 2)
|
|
.foregroundColor(foregroundColor.opacity(0.3))
|
|
.frame(width: 44, height: 44)
|
|
.scaleEffect(pulse ? 1.3 : 1)
|
|
.opacity(pulse ? 0 : 0.6)
|
|
|
|
// Icon background
|
|
Circle()
|
|
.fill(foregroundColor.opacity(accentOpacity))
|
|
.frame(width: 44, height: 44)
|
|
|
|
Image(systemName: icon)
|
|
.font(.system(size: 20, weight: .medium))
|
|
.foregroundColor(foregroundColor)
|
|
}
|
|
|
|
Text(title)
|
|
.font(.system(size: 17, weight: .semibold, design: .rounded))
|
|
.foregroundColor(foregroundColor)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 18)
|
|
.padding(.horizontal, 24)
|
|
.background(
|
|
ZStack {
|
|
// Glass effect
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(.ultraThinMaterial)
|
|
|
|
// Border gradient
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.stroke(
|
|
LinearGradient(
|
|
colors: [
|
|
foregroundColor.opacity(0.3),
|
|
foregroundColor.opacity(0.1),
|
|
foregroundColor.opacity(0.15)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
),
|
|
lineWidth: 1
|
|
)
|
|
|
|
// Inner highlight
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [foregroundColor.opacity(0.08), .clear],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
}
|
|
)
|
|
.scaleEffect(isPressed ? 0.97 : 1)
|
|
}
|
|
.buttonStyle(PlainButtonStyle())
|
|
.simultaneousGesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { _ in
|
|
withAnimation(.easeInOut(duration: 0.1)) {
|
|
isPressed = true
|
|
}
|
|
}
|
|
.onEnded { _ in
|
|
withAnimation(.easeInOut(duration: 0.1)) {
|
|
isPressed = false
|
|
}
|
|
}
|
|
)
|
|
.onAppear {
|
|
withAnimation(
|
|
.easeInOut(duration: 2)
|
|
.repeatForever(autoreverses: false)
|
|
) {
|
|
pulse = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
AuroraBackground()
|
|
|
|
// Floating particles
|
|
FloatingParticlesView()
|
|
|
|
// Main content
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
|
|
// Breathing orb
|
|
BreathingOrb()
|
|
.opacity(showContent ? 1 : 0)
|
|
.scaleEffect(showContent ? 1 : 0.8)
|
|
|
|
Spacer()
|
|
.frame(height: 50)
|
|
|
|
// Text content
|
|
VStack(spacing: 12) {
|
|
Text("Your Feelings")
|
|
.font(.system(size: 32, weight: .light, design: .serif))
|
|
.foregroundColor(primaryText)
|
|
|
|
Text("are safe here")
|
|
.font(.system(size: 32, weight: .ultraLight, design: .serif))
|
|
.foregroundColor(secondaryText)
|
|
}
|
|
.opacity(showContent ? 1 : 0)
|
|
.offset(y: showContent ? 0 : 20)
|
|
|
|
Spacer()
|
|
.frame(height: 16)
|
|
|
|
Text("Authenticate to continue your journey")
|
|
.font(.system(size: 14, weight: .regular, design: .rounded))
|
|
.foregroundColor(tertiaryText)
|
|
.opacity(showContent ? 1 : 0)
|
|
|
|
Spacer()
|
|
|
|
// Unlock button
|
|
GlassButton(
|
|
icon: authManager.biometricIcon,
|
|
title: "Unlock with \(authManager.biometricName)"
|
|
) {
|
|
Task {
|
|
let success = await authManager.authenticate()
|
|
if !success {
|
|
showError = true
|
|
}
|
|
}
|
|
}
|
|
.disabled(authManager.isAuthenticating)
|
|
.opacity(showContent ? 1 : 0)
|
|
.offset(y: showContent ? 0 : 30)
|
|
.padding(.horizontal, 32)
|
|
|
|
// Passcode button - tappable to authenticate with passcode
|
|
if authManager.canUseDevicePasscode {
|
|
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()
|
|
.frame(height: 50)
|
|
}
|
|
.padding()
|
|
}
|
|
.alert("Authentication Failed", isPresented: $showError) {
|
|
Button("Try Again") {
|
|
Task {
|
|
await authManager.authenticate()
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) { }
|
|
} message: {
|
|
Text("Unable to verify your identity. Please try again.")
|
|
}
|
|
.onAppear {
|
|
// Animate content in
|
|
withAnimation(.easeOut(duration: 0.8).delay(0.2)) {
|
|
showContent = true
|
|
}
|
|
|
|
// Auto-trigger authentication
|
|
if !authManager.isUnlocked && !authManager.isAuthenticating {
|
|
Task {
|
|
try? await Task.sleep(for: .milliseconds(800))
|
|
await authManager.authenticate()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview("Lock Screen - Dark") {
|
|
LockScreenView(authManager: BiometricAuthManager())
|
|
.preferredColorScheme(.dark)
|
|
}
|
|
|
|
#Preview("Lock Screen - Light") {
|
|
LockScreenView(authManager: BiometricAuthManager())
|
|
.preferredColorScheme(.light)
|
|
}
|