Replace PostHog integration with AnalyticsManager architecture

Remove old PostHogAnalytics singleton and replace with guide-based
two-file architecture: AnalyticsManager (singleton wrapper with super
properties, session replay, opt-out, subscription funnel) and
AnalyticsEvent (type-safe enum with associated values).

Key changes:
- New API key, self-hosted analytics endpoint
- All 19 events ported to type-safe AnalyticsEvent enum
- Screen tracking via AnalyticsManager.Screen enum + SwiftUI modifier
- Remove all identify() calls — fully anonymous analytics
- Add lifecycle hooks: flush on background, update super properties on foreground
- Add privacy opt-out toggle in Settings
- Subscription funnel methods ready for IAP integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-11 09:48:49 -06:00
parent 09be5fa444
commit 2fc4a48fc9
50 changed files with 2191 additions and 335 deletions

View File

@@ -82,13 +82,11 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let userInfo = notification.request.content.userInfo
print("📬 Notification received in foreground: \(userInfo)")
let payloadKeys = Array(userInfo.keys).map { String(describing: $0) }.sorted()
print("📬 Notification received in foreground. Keys: \(payloadKeys)")
Task { @MainActor in
PushNotificationManager.shared.handleNotification(userInfo: userInfo)
}
// Show notification even when app is in foreground
// Passive mode in foreground: present banner/sound, but do not
// mutate read state or trigger navigation automatically.
completionHandler([.banner, .sound, .badge])
}
@@ -102,7 +100,8 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
let actionIdentifier = response.actionIdentifier
print("👆 User interacted with notification - Action: \(actionIdentifier)")
print(" UserInfo: \(userInfo)")
let payloadKeys = Array(userInfo.keys).map { String(describing: $0) }.sorted()
print(" Payload keys: \(payloadKeys)")
Task { @MainActor in
// Handle action buttons or default tap

View File

@@ -11,8 +11,11 @@ class PushNotificationManager: NSObject, ObservableObject {
/// Pending task ID to navigate to (set when notification is tapped before app is ready)
@Published var pendingNavigationTaskId: Int?
@Published var pendingNavigationResidenceId: Int?
@Published var pendingNavigationDocumentId: Int?
private let registeredTokenKey = "com.casera.registeredDeviceToken"
private let registeredUserIdKey = "com.casera.registeredUserId"
/// The last token that was successfully registered with the backend
private var lastRegisteredToken: String? {
@@ -20,6 +23,12 @@ class PushNotificationManager: NSObject, ObservableObject {
set { UserDefaults.standard.set(newValue, forKey: registeredTokenKey) }
}
/// The user ID associated with the last successful registration
private var lastRegisteredUserId: String? {
get { UserDefaults.standard.string(forKey: registeredUserIdKey) }
set { UserDefaults.standard.set(newValue, forKey: registeredUserIdKey) }
}
override init() {
super.init()
}
@@ -55,7 +64,8 @@ class PushNotificationManager: NSObject, ObservableObject {
func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) {
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
self.deviceToken = tokenString
print("📱 APNs device token: \(tokenString)")
let redactedToken = "\(tokenString.prefix(8))...\(tokenString.suffix(8))"
print("📱 APNs device token: \(redactedToken)")
// Register with backend
Task {
@@ -88,12 +98,6 @@ class PushNotificationManager: NSObject, ObservableObject {
return
}
// Skip if token hasn't changed
if token == lastRegisteredToken {
print("📱 Device token unchanged, skipping registration")
return
}
Task {
await registerDeviceWithBackend(token: token, force: false)
}
@@ -105,10 +109,21 @@ class PushNotificationManager: NSObject, ObservableObject {
return
}
// Skip if token hasn't changed (unless forced)
if !force && token == lastRegisteredToken {
print("📱 Device token unchanged, skipping registration")
return
let currentUserId = await MainActor.run {
DataManagerObservable.shared.currentUser.map { String($0.id) }
}
// Skip only if both token and user identity match.
if !force, token == lastRegisteredToken {
if let currentUserId, currentUserId == lastRegisteredUserId {
print("📱 Device token already registered for current user, skipping")
return
}
if currentUserId == nil, lastRegisteredUserId == nil {
print("📱 Device token already registered, skipping")
return
}
}
// Get unique device identifier
@@ -130,12 +145,17 @@ class PushNotificationManager: NSObject, ObservableObject {
let result = try await APILayer.shared.registerDevice(request: request)
if let success = result as? ApiResultSuccess<DeviceRegistrationResponse> {
print("✅ Device registered successfully: \(success.data)")
if success.data != nil {
print("✅ Device registered successfully")
} else {
print("✅ Device registration acknowledged")
}
// Cache the token on successful registration
await MainActor.run {
self.lastRegisteredToken = token
self.lastRegisteredUserId = currentUserId
}
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
print("❌ Failed to register device: \(error.message)")
} else {
print("⚠️ Unexpected result type from device registration")
@@ -148,10 +168,10 @@ class PushNotificationManager: NSObject, ObservableObject {
// MARK: - Handle Notifications
func handleNotification(userInfo: [AnyHashable: Any]) {
print("📬 Received notification: \(userInfo)")
print("📬 Received notification: \(redactedPayloadSummary(userInfo: userInfo))")
// Extract notification data
if let notificationId = userInfo["notification_id"] as? String {
if let notificationId = stringValue(for: "notification_id", in: userInfo) {
print("Notification ID: \(notificationId)")
// Mark as read when user taps notification
@@ -171,7 +191,7 @@ class PushNotificationManager: NSObject, ObservableObject {
print("📬 Handling notification tap")
// Mark as read
if let notificationId = userInfo["notification_id"] as? String {
if let notificationId = stringValue(for: "notification_id", in: userInfo) {
Task {
await markNotificationAsRead(notificationId: notificationId)
}
@@ -188,10 +208,13 @@ class PushNotificationManager: NSObject, ObservableObject {
if canNavigateToTask {
// Navigate to task detail
if let taskIdStr = userInfo["task_id"] as? String, let taskId = Int(taskIdStr) {
navigateToTask(taskId: taskId)
} else if let taskId = userInfo["task_id"] as? Int {
if let taskId = intValue(for: "task_id", in: userInfo) {
navigateToTask(taskId: taskId)
} else if let type = userInfo["type"] as? String {
// Fall back to type-specific routing for non-task notifications.
handleNotificationType(type: type, userInfo: userInfo)
} else {
navigateToHome()
}
} else {
// Free user with limitations enabled - navigate to home
@@ -204,7 +227,7 @@ class PushNotificationManager: NSObject, ObservableObject {
print("🔘 Handling notification action: \(actionIdentifier)")
// Mark as read
if let notificationId = userInfo["notification_id"] as? String {
if let notificationId = stringValue(for: "notification_id", in: userInfo) {
Task {
await markNotificationAsRead(notificationId: notificationId)
}
@@ -224,14 +247,7 @@ class PushNotificationManager: NSObject, ObservableObject {
}
// Extract task ID
var taskId: Int?
if let taskIdStr = userInfo["task_id"] as? String {
taskId = Int(taskIdStr)
} else if let id = userInfo["task_id"] as? Int {
taskId = id
}
guard let taskId = taskId else {
guard let taskId = intValue(for: "task_id", in: userInfo) else {
print("❌ No task_id found in notification")
return
}
@@ -265,24 +281,27 @@ class PushNotificationManager: NSObject, ObservableObject {
private func handleNotificationType(type: String, userInfo: [AnyHashable: Any]) {
switch type {
case "task_due_soon", "task_overdue", "task_completed", "task_assigned":
if let taskIdStr = userInfo["task_id"] as? String, let taskId = Int(taskIdStr) {
print("Task notification for task ID: \(taskId)")
navigateToTask(taskId: taskId)
} else if let taskId = userInfo["task_id"] as? Int {
if let taskId = intValue(for: "task_id", in: userInfo) {
print("Task notification for task ID: \(taskId)")
navigateToTask(taskId: taskId)
}
case "residence_shared":
if let residenceId = userInfo["residence_id"] as? String {
if let residenceId = intValue(for: "residence_id", in: userInfo) {
print("Residence shared notification for residence ID: \(residenceId)")
// TODO: Navigate to residence detail
navigateToResidence(residenceId: residenceId)
} else {
print("Residence shared notification without residence ID")
navigateToResidencesTab()
}
case "warranty_expiring":
if let documentId = userInfo["document_id"] as? String {
if let documentId = intValue(for: "document_id", in: userInfo) {
print("Warranty expiring notification for document ID: \(documentId)")
// TODO: Navigate to document detail
navigateToDocument(documentId: documentId)
} else {
print("Warranty expiring notification without document ID")
navigateToDocumentsTab()
}
default:
@@ -313,8 +332,10 @@ class PushNotificationManager: NSObject, ObservableObject {
await MainActor.run {
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
}
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
print("❌ Failed to complete task: \(error.message)")
} else {
print("⚠️ Unexpected result while completing task \(taskId)")
}
} catch {
print("❌ Error completing task: \(error.localizedDescription)")
@@ -333,8 +354,10 @@ class PushNotificationManager: NSObject, ObservableObject {
await MainActor.run {
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
}
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
print("❌ Failed to mark task in progress: \(error.message)")
} else {
print("⚠️ Unexpected result while marking task \(taskId) in progress")
}
} catch {
print("❌ Error marking task in progress: \(error.localizedDescription)")
@@ -353,8 +376,10 @@ class PushNotificationManager: NSObject, ObservableObject {
await MainActor.run {
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
}
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
print("❌ Failed to cancel task: \(error.message)")
} else {
print("⚠️ Unexpected result while cancelling task \(taskId)")
}
} catch {
print("❌ Error cancelling task: \(error.localizedDescription)")
@@ -373,8 +398,10 @@ class PushNotificationManager: NSObject, ObservableObject {
await MainActor.run {
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
}
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
print("❌ Failed to uncancel task: \(error.message)")
} else {
print("⚠️ Unexpected result while uncancelling task \(taskId)")
}
} catch {
print("❌ Error uncancelling task: \(error.localizedDescription)")
@@ -398,6 +425,14 @@ class PushNotificationManager: NSObject, ObservableObject {
/// Clear pending navigation after it has been handled
func clearPendingNavigation() {
pendingNavigationTaskId = nil
pendingNavigationResidenceId = nil
pendingNavigationDocumentId = nil
}
/// Clear device registration cache (call on logout/account switch).
func clearRegistrationCache() {
lastRegisteredToken = nil
lastRegisteredUserId = nil
}
private func navigateToEditTask(taskId: Int) {
@@ -409,11 +444,71 @@ class PushNotificationManager: NSObject, ObservableObject {
)
}
private func navigateToResidence(residenceId: Int) {
print("🏠 Navigating to residence \(residenceId)")
pendingNavigationResidenceId = residenceId
NotificationCenter.default.post(
name: .navigateToResidence,
object: nil,
userInfo: ["residenceId": residenceId]
)
}
private func navigateToResidencesTab() {
NotificationCenter.default.post(name: .navigateToResidence, object: nil)
}
private func navigateToDocument(documentId: Int) {
print("📄 Navigating to document \(documentId)")
pendingNavigationDocumentId = documentId
NotificationCenter.default.post(
name: .navigateToDocument,
object: nil,
userInfo: ["documentId": documentId]
)
}
private func navigateToDocumentsTab() {
NotificationCenter.default.post(name: .navigateToDocument, object: nil)
}
private func navigateToHome() {
print("🏠 Navigating to home")
pendingNavigationTaskId = nil
pendingNavigationResidenceId = nil
pendingNavigationDocumentId = nil
NotificationCenter.default.post(name: .navigateToHome, object: nil)
}
private func stringValue(for key: String, in userInfo: [AnyHashable: Any]) -> String? {
if let value = userInfo[key] as? String {
return value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
if let value = userInfo[key] as? NSNumber {
return value.stringValue
}
if let value = userInfo[key] as? Int {
return String(value)
}
return nil
}
private func intValue(for key: String, in userInfo: [AnyHashable: Any]) -> Int? {
guard let raw = stringValue(for: key, in: userInfo) else {
return nil
}
return Int(raw)
}
private func redactedPayloadSummary(userInfo: [AnyHashable: Any]) -> String {
let type = stringValue(for: "type", in: userInfo) ?? "unknown"
let notificationId = stringValue(for: "notification_id", in: userInfo) ?? "missing"
let taskId = stringValue(for: "task_id", in: userInfo) ?? "none"
let residenceId = stringValue(for: "residence_id", in: userInfo) ?? "none"
let documentId = stringValue(for: "document_id", in: userInfo) ?? "none"
return "type=\(type), notification_id=\(notificationId), task_id=\(taskId), residence_id=\(residenceId), document_id=\(documentId)"
}
private func markNotificationAsRead(notificationId: String) async {
guard TokenStorage.shared.getToken() != nil,
let notificationIdInt = Int32(notificationId) else {
@@ -425,8 +520,10 @@ class PushNotificationManager: NSObject, ObservableObject {
if result is ApiResultSuccess<ComposeApp.Notification> {
print("✅ Notification marked as read")
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
print("❌ Failed to mark notification as read: \(error.message)")
} else {
print("⚠️ Unexpected result while marking notification read")
}
} catch {
print("❌ Error marking notification as read: \(error.localizedDescription)")
@@ -447,10 +544,11 @@ class PushNotificationManager: NSObject, ObservableObject {
if result is ApiResultSuccess<NotificationPreference> {
print("✅ Notification preferences updated")
return true
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
print("❌ Failed to update preferences: \(error.message)")
return false
}
print("⚠️ Unexpected result while updating notification preferences")
return false
} catch {
print("❌ Error updating notification preferences: \(error.localizedDescription)")
@@ -469,10 +567,11 @@ class PushNotificationManager: NSObject, ObservableObject {
if let success = result as? ApiResultSuccess<NotificationPreference> {
return success.data
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
print("❌ Failed to get preferences: \(error.message)")
return nil
}
print("⚠️ Unexpected result while loading notification preferences")
return nil
} catch {
print("❌ Error getting notification preferences: \(error.localizedDescription)")
@@ -490,4 +589,3 @@ class PushNotificationManager: NSObject, ObservableObject {
UNUserNotificationCenter.current().setBadgeCount(count)
}
}