Premium Features: - Journal notes and photo attachments for mood entries - Data export (CSV and PDF reports) - Privacy lock with Face ID/Touch ID - Apple Health integration for mood correlation - 4 new personality packs (Motivational Coach, Zen Master, Best Friend, Data Analyst) Settings Tab Reorganization: - Combined Customize and Settings into single tab with segmented control - Added upgrade banner with trial countdown above segment - "Why Upgrade?" sheet showing all premium benefits - Subscribe button opens improved StoreKit 2 subscription view UI Improvements: - Enhanced subscription store with feature highlights - Entry detail view for viewing/editing notes and photos - Removed duplicate subscription banners from tab content 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
174 lines
4.9 KiB
Swift
174 lines
4.9 KiB
Swift
//
|
|
// BiometricAuthManager.swift
|
|
// Feels
|
|
//
|
|
// Manages Face ID / Touch ID authentication for app privacy lock.
|
|
//
|
|
|
|
import Foundation
|
|
import LocalAuthentication
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
class BiometricAuthManager: ObservableObject {
|
|
|
|
// MARK: - Published State
|
|
|
|
@Published var isUnlocked: Bool = true
|
|
@Published var isAuthenticating: Bool = false
|
|
|
|
// MARK: - App Storage
|
|
|
|
@AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults)
|
|
var isLockEnabled: Bool = false
|
|
|
|
// MARK: - Biometric Capabilities
|
|
|
|
var canUseBiometrics: Bool {
|
|
let context = LAContext()
|
|
var error: NSError?
|
|
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
|
}
|
|
|
|
var canUseDevicePasscode: Bool {
|
|
let context = LAContext()
|
|
var error: NSError?
|
|
return context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
|
|
}
|
|
|
|
var biometricType: LABiometryType {
|
|
let context = LAContext()
|
|
var error: NSError?
|
|
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
|
return context.biometryType
|
|
}
|
|
|
|
var biometricName: String {
|
|
switch biometricType {
|
|
case .faceID:
|
|
return "Face ID"
|
|
case .touchID:
|
|
return "Touch ID"
|
|
case .opticID:
|
|
return "Optic ID"
|
|
@unknown default:
|
|
return "Biometrics"
|
|
}
|
|
}
|
|
|
|
var biometricIcon: String {
|
|
switch biometricType {
|
|
case .faceID:
|
|
return "faceid"
|
|
case .touchID:
|
|
return "touchid"
|
|
case .opticID:
|
|
return "opticid"
|
|
@unknown default:
|
|
return "lock.fill"
|
|
}
|
|
}
|
|
|
|
// MARK: - Authentication
|
|
|
|
func authenticate() async -> Bool {
|
|
guard isLockEnabled else {
|
|
isUnlocked = true
|
|
return true
|
|
}
|
|
|
|
let context = LAContext()
|
|
context.localizedCancelTitle = "Cancel"
|
|
|
|
// Try biometrics first, fall back to device passcode
|
|
let policy: LAPolicy = canUseBiometrics ? .deviceOwnerAuthenticationWithBiometrics : .deviceOwnerAuthentication
|
|
|
|
isAuthenticating = true
|
|
defer { isAuthenticating = false }
|
|
|
|
do {
|
|
let success = try await context.evaluatePolicy(
|
|
policy,
|
|
localizedReason: "Unlock Feels to access your mood data"
|
|
)
|
|
|
|
isUnlocked = success
|
|
if success {
|
|
EventLogger.log(event: "biometric_unlock_success")
|
|
}
|
|
return success
|
|
} catch {
|
|
print("Authentication failed: \(error.localizedDescription)")
|
|
EventLogger.log(event: "biometric_unlock_failed", withData: ["error": error.localizedDescription])
|
|
|
|
// If biometrics failed, try device passcode as fallback
|
|
if canUseDevicePasscode && policy == .deviceOwnerAuthenticationWithBiometrics {
|
|
return await authenticateWithPasscode()
|
|
}
|
|
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func authenticateWithPasscode() async -> Bool {
|
|
let context = LAContext()
|
|
|
|
do {
|
|
let success = try await context.evaluatePolicy(
|
|
.deviceOwnerAuthentication,
|
|
localizedReason: "Unlock Feels to access your mood data"
|
|
)
|
|
|
|
isUnlocked = success
|
|
return success
|
|
} catch {
|
|
print("Passcode authentication failed: \(error.localizedDescription)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Lock Management
|
|
|
|
func lock() {
|
|
guard isLockEnabled else { return }
|
|
isUnlocked = false
|
|
EventLogger.log(event: "app_locked")
|
|
}
|
|
|
|
func enableLock() async -> Bool {
|
|
// Authenticate first to enable lock - require biometrics
|
|
let context = LAContext()
|
|
var error: NSError?
|
|
|
|
// Only allow enabling if biometrics are available
|
|
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
|
|
print("Biometric authentication not available: \(error?.localizedDescription ?? "Unknown")")
|
|
return false
|
|
}
|
|
|
|
do {
|
|
let success = try await context.evaluatePolicy(
|
|
.deviceOwnerAuthenticationWithBiometrics,
|
|
localizedReason: "Verify your identity to enable app lock"
|
|
)
|
|
|
|
if success {
|
|
isLockEnabled = true
|
|
isUnlocked = true
|
|
EventLogger.log(event: "privacy_lock_enabled")
|
|
}
|
|
|
|
return success
|
|
} catch {
|
|
print("Failed to enable lock: \(error.localizedDescription)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
func disableLock() {
|
|
isLockEnabled = false
|
|
isUnlocked = true
|
|
EventLogger.log(event: "privacy_lock_disabled")
|
|
}
|
|
}
|